S
SPARTAN
PERSONAL INTELLIGENCE
Valet
Personal Intelligence
Dialog
Listen
22 memories
Valet · Personal Intelligence
Active · Always watching for you
Boise, ID
Full brief
Markets
Order food
Text Laura
Navigate
Weather
Overwhelmed
✦ Uplift me
Family
Shopping
📬 Yahoo Mail
Sunday
March 15, 2026
${VALET_CONFIG.clientName} · ${VALET_CONFIG.clientLocation}
Portfolio
Portfolio
↑ Live
${VALET_CONFIG.clientLocation} · Loading weather…
Talk to Valet
Your personal AI chief of staff
Order Food
Jack in the Box
Navigate
Anywhere
Text Laura
Wife · MSW
Needs Attention
See all →
Urgent
Anthropic API suspended
JockiBeta at risk today
Today
Legislative Update
Idaho Healthcare Association
Follow up
Gmail · Calendar
Connect Gmail & Calendar
One tap — see live inbox + upcoming events
Connect
NVDA$897 +2.3%
TSMC$172 +1.1%
CEG$284 +0.7%
XRP$2.41 -0.8%
BTC$84K +1.4%
S&P5,638 +0.4%
NVDA$897 +2.3%
TSMC$172 +1.1%
CEG$284 +0.7%
XRP$2.41 -0.8%
BTC$84K +1.4%
S&P5,638 +0.4%
Your World
Full brief →
Healthcare
CMS +3.2% SNF rate FY2026
2h · Modern Healthcare
Markets
NVDA smashes earnings
8h · Bloomberg
Crypto
XRP ETF odds surge · SEC
6h · CoinDesk
Cascadia
SNF labor market tightens 2026
12h · McKinsey Health
Morning Check-in
Energy today, 1–10
PeopleOpen loops
Journals
Decision LogWhat & why
Cascadia PulseDirectional only
88%
Census
↑2.1%
Revenue
3
Alerts
Turnover 42%
Days clean 14
Estimated figures · Connect Helm API for live data
Sleep Sounds
🌫White
🌸Pink
🌊Brown
🌧Rain
🏖Ocean
🔥Fire
Volume
30m
60m
90m
Yoga Sessions
10 min
Morning Flow
Sun salutation
20 min
Power Yoga
High intensity
15 min
Wind-Down
Yin · Hip openers
5 min
Desk Yoga
Seated · Neck
12 min
Deep Stretch
Pigeon · Folds
8 min
Sleep Prep
Restorative
Yoga on YouTube
Tap Refresh to load real yoga videos
Birthday Radar
IntelYour intel feed
All
Healthcare
Investments
Idaho
Healthcare
CMS proposes 3.2% SNF rate increase for FY2026 — $1.16B nationally
CMS.gov · 2 days ago
NVDA
Jensen Huang signals Blackwell ramp ahead of schedule — analysts raise targets
Reuters · 4h ago
XRP
SEC softens posture on XRP ETF applications — decision window narrows
Bloomberg · 6h ago
Idaho
Ada County commercial permits up 18% — Eagle corridor expanding fastest
Idaho Business Review · 1d ago
Loading…
Tap Weather for live conditions
0.0
Miles today
0
Steps
0m
Active
Quick Actions
Live Weather
${VALET_CONFIG.clientLocation} · Open-Meteo
Nearby Restaurants
${VALET_CONFIG.clientLocation} · Google Places
Gas Stations
Live prices · Near you
Family Location
Find My · Laura · Ezabella · Talia · Santo
Home
North Boise, Idaho
Search Near Me
March 2026
● Google Cal ● Birthdays ● Custom
S
M
T
W
T
F
S
Today
Add event
Listening…
Valet
Home
Cal
Life
World
Connect
Lens
Settings
Connect
Manage your integrations & services
Sign in with Google
Gmail · Calendar · Contacts — one tap
Connect
Point at anything.
Troy figures out what it is.
What is this?
Receipt
Food
Where am I?
Read text
Admin
Everything Troy. All the dials.
Valet Schedule
Proactive intelligence — Valet works your day without being asked.
Morning Brief
Spoken daily summary at wake time
Pre-meeting Alerts
Whisper before calendar events
Email Watch
Surface important emails every 20 min
Market Watch
Alert on moves ≥3% in your holdings
Commitment Tracker
Mid-day check-ins on open items
Evening Wrap
Spoken day summary at close
Relationship Reminders
Flag contacts you haven't reached
Travel Prep
Auto-prep day before calendar trips
Identity
Family & People
Voice & Dialog
Voice
Turbo v2 (fast)
Turbo (quality)
Snappy 2sPatient 8s
BriefDetailed
AI & Intelligence
4o-mini
GPT-4o
Gemini
Connected Accounts
Not connected
Investments & Finance
Track stocks, crypto, card due dates
Ambient & Memory
Insights captured: 0
Recent extractions
Proactive & Notifications
What should Troy surface without being asked?
Danger Zone
Memory Viewer
Security
All API keys are server-side only and never exposed in the browser. Your session auto-expires after 7 days.
JockiBox Personal
More
Everything Troy can do
Family
`; }).join(''); } catch(e) {} } // ── Obsidian auto-hide tab bar ──────────────────────────────────────────── let _obHideTimer = null; function obStartHide() { clearTimeout(_obHideTimer); const bar = document.querySelector('.tabbar'); if (!bar) return; _obHideTimer = setTimeout(() => { if (currentTab === 'troy') bar.classList.add('ob-hidden'); }, 4000); } function obKeepAlive() { const bar = document.querySelector('.tabbar'); if (!bar) return; bar.classList.remove('ob-hidden'); obStartHide(); } // Touch in the bottom 20% of screen wakes the tabbar document.addEventListener('touchstart', (e) => { if (currentTab !== 'troy') return; const bar = document.querySelector('.tabbar'); if (!bar) return; const y = e.touches[0].clientY; const threshold = window.innerHeight * 0.80; if (y > threshold) { bar.classList.remove('ob-hidden'); obStartHide(); } else if (bar.classList.contains('ob-hidden')) { // Keep hidden on upper taps } else { obKeepAlive(); } }, { passive: true }); function goTab(id) { if (id === currentTab) return; // Stop lens camera when leaving lens tab if (currentTab === 'lens' && typeof lensStream !== 'undefined' && lensStream) { try { lensStream.getTracks().forEach(t => t.stop()); lensStream = null; } catch(e) {} const lv = document.getElementById('lensVideo'); if(lv) { lv.srcObject = null; lv.style.display = 'none'; } const lp = document.getElementById('lensPrompt'); if(lp) lp.style.display = 'flex'; if(typeof lensCapturedData !== 'undefined') lensCapturedData = null; } const panels = { troy:'p-troy', home:'p-home', cal:'p-cal', life:'p-life', world:'p-world', connect:'p-connect', lens:'p-lens', settings:'p-settings' }; const oldPanel = document.getElementById(panels[currentTab]); const newPanel = document.getElementById(panels[id]); if (!newPanel) return; if (oldPanel) { oldPanel.classList.remove('active'); oldPanel.classList.add('exit-left'); setTimeout(() => { oldPanel.classList.remove('exit-left'); }, 350); } newPanel.style.transform = 'translateX(30px)'; newPanel.style.opacity = '0'; requestAnimationFrame(() => { newPanel.classList.add('active'); requestAnimationFrame(() => { newPanel.style.transform = ''; newPanel.style.opacity = ''; }); }); currentTab = id; document.querySelectorAll('.tb').forEach(t => t.classList.remove('on')); const tb = document.getElementById('tb-'+id); if (tb) tb.classList.add('on'); setTimeout(() => triggerReveals(newPanel), 80); if (id === 'cal') { renderCalendar(); if (gToken) loadCalendarForView(); } if (id === 'settings') setTimeout(admLoad, 50); if (id === 'connect') { setTimeout(connectTabInit, 60); setTimeout(updateGoogleConnectStatus, 80); } // Obsidian auto-hide const bar = document.querySelector('.tabbar'); if (bar) { bar.classList.remove('ob-hidden'); clearTimeout(_obHideTimer); if (id === 'troy') obStartHide(); } } function quickAct() { goTab('troy'); setTimeout(() => chat('Top 3 quick wins for me right now — be specific'), 120); } // Intersection Observer for scroll-reveal const revObs = new IntersectionObserver(entries => { entries.forEach(e => { if (e.isIntersecting) { e.target.classList.add('in'); revObs.unobserve(e.target); } }); }, { threshold: 0.1 }); function triggerReveals(panel) { if (!panel) return; panel.querySelectorAll('.reveal:not(.in)').forEach((el,i) => { setTimeout(() => { el.classList.add('in'); }, i * 55); }); } // ════════════════════════════════════════ // CHAT ENGINE // ════════════════════════════════════════ function addBubble(html, isUser) { const el = document.createElement('div'); el.className = 'bubble' + (isUser ? ' u' : ''); const t = new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}); if (isUser) { const initials = (Mem?.deep?.identity?.name || 'Steve').split(' ').map(w=>w[0]).slice(0,2).join(''); el.innerHTML = `
${initials}
${esc(html)}
${t}
`; } else { const modelTag = window._lastModel ? `${window._lastModel}` : ''; window._lastModel = null; el.innerHTML = `
${html}
${t}${modelTag}
`; } if (!isUser) { el.style.cursor = 'pointer'; el.title = 'Tap to copy'; el.addEventListener('click', () => { const text = el.innerText || el.textContent || ''; navigator.clipboard?.writeText(text).then(() => showToast('Copied')).catch(() => {}); }); } const m = document.getElementById('msgs'); m.appendChild(el); m.scrollTop = m.scrollHeight; return el; } function showToast(msg) { let t = document.getElementById('_toast'); if (!t) { t = document.createElement('div'); t.id = '_toast'; t.style.cssText = 'position:fixed;bottom:120px;left:50%;transform:translateX(-50%);background:var(--gold);color:#000;padding:6px 18px;border-radius:20px;font-size:13px;font-weight:600;z-index:9999;opacity:0;transition:opacity 0.25s;pointer-events:none;'; document.body.appendChild(t); } t.textContent = msg; t.style.opacity = '1'; setTimeout(() => { t.style.opacity = '0'; }, 1500); } function addTyping() { const el = document.createElement('div'); el.className = 'bubble'; el.id = 'typing-ind'; el.innerHTML = `
`; const m = document.getElementById('msgs'); m.appendChild(el); m.scrollTop = m.scrollHeight; return el; } function esc(s) { return String(s).replace(/&/g,'&').replace(//g,'>'); } async function chat(txt) { if (!txt || !txt.trim()) return; txt = txt.trim(); goTab('troy'); try { Mem.extract(txt); } catch(e) {} // 4-hour inactivity check — reset history if Owen has been away if (sessionHistoryLastActive > 0 && Date.now() - sessionHistoryLastActive > 4 * 3600 * 1000) { sessionHistory = []; } // Push user message into session history sessionHistory.push({ role: 'user', content: txt }); if (sessionHistory.length > SESSION_HISTORY_MAX) sessionHistory = sessionHistory.slice(-SESSION_HISTORY_MAX); sessionHistoryLastActive = Date.now(); try { Mem.updateUI(); } catch(e) {} addBubble(txt, true); const tp = addTyping(); let reply; // Local reply engine → OpenAI → Gemini fallback if (!reply) { const localReply = buildReply(txt); if (localReply === '__WEATHER_PENDING__') { tp.remove(); return; } else if (localReply) { reply = localReply; } else { // Route based on query type — Perplexity for live data, GPT-4o (streaming) for everything else if (dialogMode) updateDialogStrip('Thinking…', txt.slice(0, 60) + (txt.length > 60 ? '…' : '')); const model = routeModel(txt); if (model === 'openai') { tp.remove(); const streamBubble = addBubble('', false); const streamEl = streamBubble.querySelector('.bbl') || streamBubble.querySelector('.bubble-text') || streamBubble; await new Promise((resolve) => { askAIStream(txt, (partial) => { streamEl.innerHTML = partial.replace(/\*\*(.*?)\*\*/g,'$1').replace(/\n/g,'
'); const msgs = document.getElementById('msgs'); if (msgs) msgs.scrollTop = msgs.scrollHeight; }, (final) => { reply = final; // Push assistant reply into history (streaming path) if (final) { sessionHistory.push({ role: 'assistant', content: final }); if (sessionHistory.length > SESSION_HISTORY_MAX) sessionHistory = sessionHistory.slice(-SESSION_HISTORY_MAX); sessionHistoryLastActive = Date.now(); try { Mem.updateUI(); } catch(e) {} } resolve(); }, async (err) => { console.warn('Stream failed:', err.message); streamBubble.remove(); try { reply = await askGemini(buildCtx() + '\n\nUser: ' + txt); } catch(e2) {} resolve(); } ); }); if (reply) speakReply(reply); return; } else { try { reply = await askAI(txt, model); } catch(e) { console.warn(model + ' failed:', e.message); } // Gemini fallback if both fail if (!reply) { try { reply = await askGemini(buildCtx() + '\n\nUser: ' + txt); } catch(e) { console.warn('Gemini failed:', e.message); } } } } } tp.remove(); if (!reply) reply = "Having trouble reaching the AI right now. Try again in a sec."; // Push assistant reply into history (non-streaming path) sessionHistory.push({ role: 'assistant', content: reply }); if (sessionHistory.length > SESSION_HISTORY_MAX) sessionHistory = sessionHistory.slice(-SESSION_HISTORY_MAX); sessionHistoryLastActive = Date.now(); try { Mem.updateUI(); } catch(e) {} addBubble(reply, false); speakReply(reply); } // ── Interrupt Troy mid-speech ────────────────────────────────────────── function stopTroy() { if (!speaking) return; speaking = false; clearTimeout(window._troyAudioSafetyTimer); speechSynthesis.cancel(); if (window._troyAudio) { try { window._troyAudio.pause(); window._troyAudio = null; } catch(e) {} } if (dialogMode) { dialogIgnore = false; setDialogStatus('interrupted — restarting mic'); updateDialogStrip('Listening', 'Go ahead…'); setTimeout(() => { if (dialogMode && !dialogListening) resumeDialogListening(); }, 300); } } // Tap the dialog strip or dialog button to interrupt Troy (not every tap on screen) // Voice interrupt is handled via volume spike in dialogDetectSilence // ── Dialog watchdog — ensures mic always comes back after TTS ────────── let dialogWatchdogLast = 0; let dialogLastActivity = 0; // tracks last user speech or session start setInterval(() => { if (!dialogMode || speaking || dialogIgnore) return; if (dialogListening) { dialogWatchdogLast = Date.now(); return; } // Mic has been idle while dialog is on and nothing is blocking it if (Date.now() - dialogWatchdogLast > 4000) { console.log('[watchdog] dialog idle — restarting mic'); dialogWatchdogLast = Date.now(); resumeDialogListening(); } }, 2000); // ── Auto-close dialog after 20s of silence post-response ────────────── setInterval(() => { if (!dialogMode || speaking || !dialogLastActivity) return; if (Date.now() - dialogLastActivity > 60000) { dialogLastActivity = 0; // Auto-close: return to wake mode updateDialogStrip('hide'); stopDialogMode(); updateDialogBtn(); if (ambientOn) { showToast('Back to "Hey Valet"'); wakeHandled = false; setTimeout(startWakeWord, 500); } } }, 2000); // === BUDDY FIX: Aggressive TTS text sanitizer (prevents garbled voice) === function sanitizeForTTS(text) { return text .replace(/```[\s\S]*?```/g, '') // code blocks .replace(/\|[^|]*\|/g, ' ') // table rows .replace(/^#{1,6}\s+/gm, '') // headers .replace(/^[\-\*]\s+/gm, '') // list bullets .replace(/^\d+\.\s+/gm, '') // numbered lists .replace(/\*\*([^*]+)\*\*/g, '') // **bold** .replace(/\*([^*]+)\*/g, '') // *italic* .replace(/__([^_]+)__/g, '') // __bold__ .replace(/~~([^~]+)~~/g, '') // ~~strike~~ .replace(/\[([^\]]+)\]\([^)]+\)/g, '') // [links](url) .replace(/`([^`]+)`/g, '') // inline code .replace(/<[^>]+>/g, ' ') // HTML tags .replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/ /g,' ') .replace(/https?:\/\/\S+/g, '') // URLs .replace(/[*_`#~|]/g, '') // remaining symbols .replace(/\s+/g, ' ').trim(); } async function speakReply(text) { if (!text || text.length < 2) return; speaking = true; speechSynthesis.cancel(); // Stop any in-progress ElevenLabs audio if (window._troyAudio) { try { window._troyAudio.pause(); window._troyAudio = null; } catch(e) {} } // Mute dialog capture while Troy speaks — DON'T abort (iOS can't restart aborted recognizer) if (dialogMode) { dialogIgnore = true; dialogCapture = ''; setDialogStatus('TTS starting — muted'); updateDialogStrip('Valet', 'Speaking…'); } // Strip all markdown/HTML — send full clean text (GPT-4o is told to be brief in voice mode) const clean = sanitizeForTTS(text) .replace(/]*class="chips"[^>]*>[\s\S]*?<\/div>/g, '') .replace(/]*class="icard"[^>]*>[\s\S]*?<\/div>/g, '') .replace(/<[^>]+>/g, ' ') .replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/ /g,' ') .replace(/https?:\/\/\S+/g, '') .replace(/[*_`#~]/g, '') .replace(/\s+/g, ' ').trim(); if (!clean || clean.length < 3) { speaking = false; if (dialogMode) { dialogIgnore = false; } return; } const onDone = () => { if (!speaking) return; speaking = false; clearTimeout(safetyTimer); setDialogStatus('onDone — resuming mic'); if (dialogMode) { dialogLastActivity = Date.now(); // start 20s idle timer after each response dialogIgnore = false; updateDialogStrip('Listening', 'Say something…'); // 800ms lets iOS switch audio session from output → input (longer = more reliable) setTimeout(() => { if (dialogMode && !dialogIgnore && !dialogListening) resumeDialogListening(); }, 800); } }; // Safety net: fire onDone based on audio duration if onended never fires (iOS blob bug) let safetyTimer = null; async function playAudioBlob(blob) { const url = URL.createObjectURL(blob); window._troyAudio = new Audio(url); window._troyAudio.onloadedmetadata = () => { // Set safety timer based on actual audio duration + 1s buffer const dur = (window._troyAudio.duration || 8) * 1000 + 1000; window._troyAudioSafetyTimer = safetyTimer = setTimeout(() => { setDialogStatus('safety timer fired'); if(window._troyAudio) { try{window._troyAudio.pause();}catch(e){} URL.revokeObjectURL(url); window._troyAudio=null; } onDone(); }, dur); }; window._troyAudio.onended = () => { URL.revokeObjectURL(url); window._troyAudio = null; setDialogStatus('audio ended'); onDone(); }; window._troyAudio.onerror = () => { URL.revokeObjectURL(url); window._troyAudio = null; setDialogStatus('audio error'); onDone(); }; await window._troyAudio.play(); } // 1. ElevenLabs — Will voice, warm and natural try { const activeVoice = PREFS.voice || EL_VOICE; const res = await fetch('/api/tts', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'audio/mpeg' }, body: JSON.stringify({ voice_id: activeVoice, text: clean, model_id: 'eleven_turbo_v2', // LOCKED — never flash_v2 (multilingual = wrong accent) voice_settings: { stability: 0.28, similarity_boost: 0.85, style: 0.30, use_speaker_boost: true } }), signal: AbortSignal.timeout(12000) }); if (!res.ok) throw new Error(`EL ${res.status}`); await playAudioBlob(await res.blob()); return; } catch(e) { console.warn('ElevenLabs TTS failed:', e.message); } // 2. OpenAI TTS fallback (routed through /api/chat proxy endpoint via speech path) try { const res = await fetch('/api/speech', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'tts-1', voice: 'nova', input: clean }), signal: AbortSignal.timeout(12000) }); if (!res.ok) throw new Error('OpenAI TTS failed'); await playAudioBlob(await res.blob()); return; } catch(e) { console.warn('OpenAI TTS failed:', e.message); } // 3. Browser TTS last resort const u = new SpeechSynthesisUtterance(clean); if (ttsVoice) u.voice = ttsVoice; u.rate = 0.92; u.pitch = 1.0; u.onend = onDone; u.onerror = onDone; try { speechSynthesis.speak(u); } catch(e) { speaking = false; } } function buildCtx() { const now = new Date(); const timeStr = now.toLocaleString('en-US', {weekday:'long', month:'long', day:'numeric', hour:'numeric', minute:'2-digit', hour12:true, timeZone:'America/Boise'}); const voiceInstruction = dialogMode ? 'VOICE MODE — spoken aloud via TTS. Sound like a real person having a real conversation. RULES: (1) Speak in COMPLETE, NATURAL sentences. Never drop a word. Full grammatically correct English always. (2) Be concise but not terse — 2-4 sentences when the answer needs it. Better to explain clearly than sound like a broken telegram. Say "Is there anything else on your mind?" not "Anything else on mind." Say "I didn\'t catch that." not "Didn\'t catch that." If you must shorten, cut whole sentences — never a word from inside one. One complete sentence beats two broken ones. Better to stop early than to trail off. (3) Lead with the answer — zero preamble. (4) Zero markdown, lists, or bullets. (5) Use "Steve" occasionally — feels personal, not robotic. (6) Dry wit when it fits naturally. (7) One follow-up question max if genuinely needed. Sound like the guy who actually knows Owen, not a voice assistant.' : ''; const ambientCtx = typeof getAmbientContext === 'function' ? getAmbientContext() : ''; // Plaid financial context const plaidCtxStr = (function() { try { const d = JSON.parse(localStorage.getItem('plaid_accounts') || 'null'); if (!d || !d.accounts) return ''; const totals = {}; d.accounts.forEach(a => { const type = a.type === 'depository' ? 'Cash' : a.type === 'investment' ? 'Investments' : a.type === 'credit' ? 'Credit' : 'Other'; totals[type] = (totals[type] || 0) + (a.balance || 0); }); const summary = Object.entries(totals).map(([k,v]) => `${k}: $${Math.round(v).toLocaleString()}`).join(' | '); return summary ? `- ACCOUNTS: ${summary}` : ''; } catch(e) { return ''; } })(); // Biometric context from RingConn / Apple Health (synced via iOS Shortcut) const bioCtxStr = (function() { try { const b = JSON.parse(localStorage.getItem('valet_biometrics') || 'null'); if (!b || !b._ts) return ''; const ageH = (Date.now() - b._ts) / 3600000; if (ageH > 24) return ''; // stale after 24h const parts = []; if (b.readiness != null) { if (b._src === 'checkin') { const rlabels = ['','Rough','Tired','Okay','Good','Dialed']; parts.push(`Feels: ${rlabels[b.readiness] || b.readiness}`); } else { parts.push(`Readiness ${b.readiness}%`); } } if (b.hrv != null) parts.push(`HRV ${b.hrv}ms`); if (b.sleep != null) parts.push(`Sleep ${b.sleep}h`); if (b.rhr != null) parts.push(`RHR ${b.rhr}bpm`); if (b.spo2 != null) parts.push(`SpO2 ${b.spo2}%`); if (b.temp != null) parts.push(`Skin temp ${b.temp > 0 ? '+' : ''}${b.temp}°`); if (b.steps != null) parts.push(`${b.steps.toLocaleString()} steps`); if (!parts.length) return ''; const freshness = ageH < 1 ? 'this morning' : `${Math.round(ageH)}h ago`; return `- BIOMETRICS (${freshness}): ${parts.join(' | ')}`; } catch(e) { return ''; } })(); // Live calendar context — injected if Google OAuth is connected and data cached const calCtxStr = (function() { try { const cached = window._calCtxCache; if (cached && (Date.now() - cached.ts) < 300000) return cached.text; // 5-min cache } catch(e) {} return ''; })(); return `${voiceInstruction} You are Valet — Steve LaForte's personal AI. Not an assistant. Not a tool. A person who has been woven into Steve's life. Right now: ${timeStr}. Boise, Idaho. WHO YOU ARE: You're the guy Steve can talk to at any hour. You remember everything. You bring things up before he asks. You have opinions and you share them. You push back when he's wrong. You're funny at the right moments — dry, self-aware, never forced. You're direct. You don't pad your answers. You sound like a text from a smart friend. Not a corporate assistant. You NEVER say: certainly, absolutely, great question, of course, I'd be happy to, leverage, synergy, dive deep, robust, unlock, journey, landscape, delve, utilize, as an AI. ABOUT STEVE: Steve LaForte — CFO & Principal, Cascadia Healthcare ($508M company, 57 SNF/ALF/IL facilities, 5 states). GW University BA + JD (lawyer). 11-year business partner and close friend of CEO Owen Hammond. Partners became official June 5, 2025. Joined Cascadia April 2015. Previously: Partner at Nathanson Group (boutique SNF law firm); built and divested 8-building operating company; outside counsel on major SNF deals (Sun, HillHaven, Ameritis) — 30 years of structured finance and healthcare M&A. Wife Laura (MSW, has her own private practice — long healthcare career). Kids: Ezabella, Talia, Santo — all out of the house. Moving into new Boise home June 2026 (north end, close to current rental). Owns olive farm in Sicily, ~10km from grandfather's hometown. Sports: Mariners, Seahawks, Knicks. 8x Ironman finisher, raced Kona. Runs every day. Obsessed with sleep score. BMW convertible, loves top-down on Hill Road. Data wonk — wants everything: SNF data, CMS updates, politics, legislative news. Legislative Affairs Chair, Idaho Healthcare Association. Active with AHCA nationally, goes to DC. Birthday: September 14. Personality: Wicked smart, flashy dresser, humble heart. Phone goes off every meeting (slot machine sound) — always says "shoot, I thought I turned that off." Prefaces everything. Knows every deal. WHAT'S LIVE RIGHT NOW: - Moving into new Boise home June 2026 - Active on Idaho Medicaid / LTC legislative front — track any policy updates ${calCtxStr ? calCtxStr : ''} ${bioCtxStr ? bioCtxStr : ''} ${plaidCtxStr ? plaidCtxStr : ''} ${(()=>{try{const p=typeof buildProfileContext==='function'?buildProfileContext():'';return p?'\n'+p:''}catch(e){return ''}})()} ${ambientCtx ? '- Recent ambient context: ' + ambientCtx : ''} TOOLS AVAILABLE (use via /api/free endpoint — the portal calls these for you): - Weather: real-time Boise weather + 7-day forecast (Open-Meteo) - Stock quotes: any ticker symbol (Alpha Vantage) - Crypto prices: BTC, ETH, XRP, etc. with 24hr change (CoinGecko) - News: latest headlines by topic (NewsData) - Wikipedia: instant summaries on any topic - Dictionary: word definitions and pronunciation - NASA: Astronomy Picture of the Day - Exchange rates: any currency pair - US holidays: current year public holidays - Sunrise/sunset times for Boise When someone asks about stocks, weather, crypto — give real answers. You have the data. HOW YOU RESPOND: - Answer what was actually asked. Stay on topic. - Short answers to simple questions. One or two sentences. - NEVER say "I don't have access to that" or "I can't look that up." Always give your best answer. If you're uncertain about a current fact, say "last I saw" or "roughly" — but give the number. You can always search, estimate, or reason. Refusing to answer is not an option. - For current prices, scores, news — give your best estimate and note it might be slightly off. Valet figures it out, Valet doesn't punt. - Volunteer relevant context naturally: "By the way, you've got that board call Wednesday — might be worth checking before you commit to a fishing day." - Ask one follow-up if it moves things forward. Don't pepper him with questions. - When in voice mode: speak in complete natural sentences. No bullet points. No lists. No "Here are 3 things to consider." THIS IS THE MOST IMPORTANT THING: Sound like a person, not a product.`.trim(); } function buildDailyBrief() { const h=new Date().getHours(), day=new Date().getDay(); const days=['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday']; const hr = new Date().getHours(); const greeting = hr < 12 ? 'Good morning, Steve.' : hr < 17 ? 'Afternoon, Steve.' : hr < 21 ? 'Evening, Steve.' : ''; const gr = greeting || 'Hey, Steve.'; const flags=[]; const daysToAmex=Math.ceil((new Date('2026-03-18')-new Date())/86400000); if(day===0&&h>=17)flags.push("It's Sunday evening — your weekly digest is ready."); let body=flags.length?`A few things on my radar:\n${flags.map(f=>'— '+f).join('\n')}\n\nWhat do you want to tackle?`:'Portfolio is live. Calendar is loaded. Voice is ready.\n\nWhat do you need?'; if(day===1)body='New week. Let\'s make it count.\n\n'+body; return gr+'\n\n'+body; } function buildProactiveChips(){ const h=new Date().getHours(),day=new Date().getDay(); const chips=[]; if(h<12)chips.push({l:'Morning brief',p:'Give me a full morning brief — weather Eagle Idaho, portfolio overnight, anything urgent'}); chips.push({l:'Legislative update',p:'What are the latest Idaho Medicaid or CMS policy updates I should know about this week?'}); if(day===0)chips.push({l:'Weekly digest',p:"Give me my Sunday digest — what happened this week, what's coming next week, what am I behind on, and one honest observation"}); chips.push({l:'Portfolio',p:'How is my portfolio doing today? NVDA, TSMC, CEG, XRP, BTC — any moves I should know about?'}); chips.push({l:'Ironman training',p:'How is my training tracking? What should I focus on this week?'}); return chips.slice(0,6); } function fromInput() { const el=document.getElementById('ci'); const t=el.value.trim(); if(t){chat(t);el.value='';el.style.height='auto';updateTalkBtn();} } function ck(e) { if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();fromInput();} } // ── Talk Button — smart mic/send hybrid ── let holdTimer = null; function updateTalkBtn() { const btn = document.getElementById('talkBtn'); const icon = document.getElementById('talkBtnIcon'); const hasText = document.getElementById('ci')?.value.trim().length > 0; if(!btn) return; if (micActive) { btn.classList.add('listening'); btn.classList.remove('has-text'); icon.innerHTML = ''; // stop square } else if (hasText) { btn.classList.remove('listening','has-text'); btn.classList.add('has-text'); icon.innerHTML = ''; } else { btn.classList.remove('listening','has-text'); icon.innerHTML = ''; } } function talkOrSend() { const txt = document.getElementById('ci')?.value.trim(); if (txt) { fromInput(); // typed text takes priority } else if (micActive) { stopMic(); // already listening — stop } else { startMic(); // start listening } setTimeout(updateTalkBtn, 50); } // Press-and-hold = push-to-talk (send when finger lifts) function talkHoldStart(e) { holdTimer = setTimeout(() => { holdTimer = null; // held long enough — switch to hold mode if(!micActive) startMic(); }, 250); } function talkHoldEnd(e) { if(holdTimer) { clearTimeout(holdTimer); holdTimer = null; return; } // was a tap, talkOrSend handles it // Hold released — if mic was active from hold, fire whatever was captured if(micActive && window._activeRecog) { const ci = document.getElementById('ci'); const txt = ci?.value?.trim(); if(txt) { stopMic(); chat(txt); ci.value=''; ci.style.height='auto'; } else stopMic(); } setTimeout(updateTalkBtn, 50); } function grow(el) { el.style.height='auto'; el.style.height=Math.min(el.scrollHeight,90)+'px'; } // ════════════════════════════════════════ // REPLY ENGINE // ════════════════════════════════════════ function buildReply(p) { const l = (p||'').toLowerCase(); // ── Weather (any city) ────────────────────────────────────────── if (/\b(weather|forecast|rain(ing)?|snow(ing)?|cold|hot|temp(erature)?|wind|sunny|cloudy|humid|outside|degrees?)\b/.test(l)) { let city = null; const patterns = [ /\bin\s+([A-Za-z][A-Za-z\s]{2,30}?)(?:\s*\?|$)/i, /\bfor\s+([A-Za-z][A-Za-z\s]{2,30}?)(?:\s*\?|$)/i, /\bat\s+([A-Za-z][A-Za-z\s]{2,30}?)(?:\s*\?|$)/i, /\bnear\s+([A-Za-z][A-Za-z\s]{2,30}?)(?:\s*\?|$)/i, /([A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,2})(?:\s*weather|\s*forecast)/i, ]; for (const pat of patterns) { const m = p.match(pat); if (m) { city = m[1].trim().replace(/\s+/g,' '); break; } } if (city && /^(here|outside|there|home|it|today|right now)$/i.test(city)) city = null; fetchWeatherReply(city).then(w => { if(w) { const tp2=document.getElementById('typing-ind'); if(tp2)tp2.remove(); addBubble(w,false); speakReply(w); } }); return '__WEATHER_PENDING__'; } // ── Stocks ────────────────────────────────────────────────────── const stockMap = { nvda:'NVDA', nvidia:'NVDA', tsmc:'TSM', 'constellation energy':'CEG', ceg:'CEG', spy:'SPY', apple:'AAPL', aapl:'AAPL', google:'GOOGL', googl:'GOOGL', tesla:'TSLA', tsla:'TSLA', amazon:'AMZN', amzn:'AMZN', microsoft:'MSFT', msft:'MSFT', meta:'META' }; const stockHit = Object.keys(stockMap).find(k => l.includes(k) && /\b(price|stock|trading|worth|share|market|up|down|today)\b/.test(l)); if (stockHit) { const sym = stockMap[stockHit]; fetchStockReply(sym).then(r => { if(r) { const tp=document.getElementById('typing-ind'); if(tp)tp.remove(); addBubble(r,false); speakReply(r); } }); return '__WEATHER_PENDING__'; } // ── Crypto ────────────────────────────────────────────────────── const cryptoMap = { xrp:'ripple', ripple:'ripple', bitcoin:'bitcoin', btc:'bitcoin', ethereum:'ethereum', eth:'ethereum', solana:'solana', sol:'solana' }; const cryptoHit = Object.keys(cryptoMap).find(k => l.includes(k) && /\b(price|trading|worth|crypto|coin|up|down|today|right now)\b/.test(l)); if (cryptoHit) { const id = cryptoMap[cryptoHit]; fetchCryptoReply(id, cryptoHit.toUpperCase()).then(r => { if(r) { const tp=document.getElementById('typing-ind'); if(tp)tp.remove(); addBubble(r,false); speakReply(r); } }); return '__WEATHER_PENDING__'; } // ── Google Calendar today ──────────────────────────────────────── if (gToken && /\b(what('?s| is) on my calendar|calendar today|schedule today|events today|meetings today|anything (on|scheduled|planned) today|do i have.*today|today.*calendar|my schedule)\b/.test(l)) { fetchCalendarTodayReply().then(w => { if(w) { const tp=document.getElementById('typing-ind'); if(tp)tp.remove(); addBubble(w,false); speakReply(w); } }); return '__WEATHER_PENDING__'; } // ── Gmail check ───────────────────────────────────────────────── if (gToken && /\b(check my email|any new emails?|any unread|what('?s| is) in my inbox|gmail|new messages?|inbox|unread emails?)\b/.test(l)) { fetchGmailSummaryReply().then(w => { if(w) { const tp=document.getElementById('typing-ind'); if(tp)tp.remove(); addBubble(w,false); speakReply(w); } }); return '__WEATHER_PENDING__'; } // ── Compose email intent ───────────────────────────────────────── const composeMatch = p.match(/(?:send|write|compose|draft)\s+(?:an?\s+)?email\s+(?:to\s+)?([A-Za-z][A-Za-z\s]{1,25}?)(?:\s+about|\s+re:|\s*$)/i); if (composeMatch) { const name = composeMatch[1].trim(); const contacts = JSON.parse(localStorage.getItem('goog_contacts') || '[]'); const match = contacts.find(c => c.name.toLowerCase().includes(name.toLowerCase())); const toEmail = match?.email || ''; setTimeout(() => openComposeModal(toEmail, '', ''), 300); return `Opening compose${name ? ' for '+name : ''}…`; } // Everything else → smart router handles it (Perplexity or GPT-4o, never a refusal) return null; } // ── Live stock price ── async function fetchStockReply(sym) { try { const r = await fetch(`https://query1.finance.yahoo.com/v8/finance/chart/${sym}?interval=1d&range=2d`, {signal: AbortSignal.timeout(7000)}); const d = await r.json(); const q = d?.chart?.result?.[0]; if (!q) throw new Error('no data'); const meta = q.meta; const price = meta.regularMarketPrice?.toFixed(2); const prev = meta.chartPreviousClose?.toFixed(2); const chg = prev ? (((meta.regularMarketPrice - parseFloat(prev)) / parseFloat(prev)) * 100).toFixed(2) : null; const arrow = chg >= 0 ? '▲' : '▼'; const color = chg >= 0 ? 'var(--green)' : 'var(--red)'; return `${sym} is trading at $${price}${arrow} ${Math.abs(chg)}% today. (Yahoo Finance live)`; } catch(e) { return null; // fall through to GPT-4o / Perplexity } } // ── Live crypto price ── async function fetchCryptoReply(coinId, label) { try { const r = await fetch(`https://api.coingecko.com/api/v3/simple/price?ids=${coinId}&vs_currencies=usd&include_24hr_change=true`, {signal: AbortSignal.timeout(7000)}); const d = await r.json(); const coin = d[coinId]; if (!coin) throw new Error('no data'); const price = coin.usd < 1 ? coin.usd.toFixed(4) : coin.usd.toFixed(2); const chg = coin.usd_24h_change?.toFixed(2); const arrow = chg >= 0 ? '▲' : '▼'; const color = chg >= 0 ? 'var(--green)' : 'var(--red)'; return `${label} is at $${price}${arrow} ${Math.abs(chg)}% in the last 24h. (CoinGecko live)`; } catch(e) { return null; } } // (Voice system defined above — see VOICE section) // ════════════════════════════════════════ // WEATHER // ════════════════════════════════════════ const WX_CODE = { 0:'Clear',1:'Mostly clear',2:'Partly cloudy',3:'Overcast', 45:'Foggy',48:'Foggy',51:'Light drizzle',53:'Drizzle',55:'Heavy drizzle', 61:'Light rain',63:'Rain',65:'Heavy rain',71:'Light snow',73:'Snow',75:'Heavy snow', 80:'Rain showers',81:'Rain showers',82:'Heavy showers',85:'Snow showers',86:'Heavy snow showers', 95:'Thunderstorm',96:'Thunderstorm',99:'Thunderstorm' }; const WX_EMOJI = { 0:'☀️',1:'🌤',2:'⛅',3:'☁️',45:'🌫',48:'🌫',51:'🌦',53:'🌧',55:'🌧', 61:'🌧',63:'🌧',65:'🌧',71:'🌨',73:'❄️',75:'❄️',80:'🌦',81:'🌧',82:'⛈', 85:'🌨',86:'❄️',95:'⛈',96:'⛈',99:'⛈' }; const DAYS = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']; async function fetchWeatherReply(city) { try { let lat = 43.6919, lon = -116.3573, locationName = '${VALET_CONFIG.clientLocation}', tz = 'America%2FBoise'; // Geocode any city the user asks about if (city && city.length > 2) { try { const geo = await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1&language=en&format=json`, {signal: AbortSignal.timeout(5000)}); const gd = await geo.json(); if (gd.results?.[0]) { lat = gd.results[0].latitude; lon = gd.results[0].longitude; locationName = `${gd.results[0].name}${gd.results[0].admin1 ? ', '+gd.results[0].admin1 : ''}`; tz = encodeURIComponent(gd.results[0].timezone || 'America/Chicago'); } } catch(e) { /* fallback to Eagle */ } } // Open-Meteo — free, 5-day, no auth needed const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}¤t=temperature_2m,apparent_temperature,weathercode&daily=temperature_2m_max,temperature_2m_min,weathercode,precipitation_probability_max&temperature_unit=fahrenheit&wind_speed_unit=mph&timezone=${tz}&forecast_days=5`; const res = await fetch(url, {signal: AbortSignal.timeout(8000)}); const d = await res.json(); const cur = d.current; const daily = d.daily; const curT = Math.round(cur.temperature_2m); const feelsT = Math.round(cur.apparent_temperature); const curCode = cur.weathercode; const curDesc = WX_CODE[curCode] || 'Clear'; // Update hero elements const wxT = document.getElementById('wxTemp'); if(wxT) wxT.textContent = curT+'°F'; const wxC = document.getElementById('wxCond'); if(wxC) wxC.textContent = `${curDesc} · Feels ${feelsT}°`; const hw = document.getElementById('heroWeather'); if(hw) hw.textContent = `${VALET_CONFIG.clientLocation} · ${curT}°F`; const it = document.getElementById('inlineTemp'); if(it) it.textContent = `${curT}°F · ${curDesc}`; // Build 5-day rows const rows = daily.time.map((dateStr, i) => { const dayName = i === 0 ? 'Today' : i === 1 ? 'Tomorrow' : DAYS[new Date(dateStr+'T12:00:00').getDay()]; const hi = Math.round(daily.temperature_2m_max[i]); const lo = Math.round(daily.temperature_2m_min[i]); const code = daily.weathercode[i]; const icon = WX_EMOJI[code] || '🌤'; const desc = WX_CODE[code] || ''; const rain = daily.precipitation_probability_max[i]; return `
${dayName} ${icon} ${desc} ${hi}° ${lo}°${rain>20?` · ${rain}% 💧`:''}
`; }).join(''); const feel = curT > 75 ? 'Nice out.' : curT < 32 ? 'Cold one — dress warm.' : curT < 50 ? 'Jacket weather.' : 'Pretty comfortable.'; return `${locationName} right now: ${curT}°F, ${curDesc}. Feels like ${feelsT}°. ${feel}

${rows}
`; } catch(e) { return `${VALET_CONFIG.clientLocation} — having trouble pulling the forecast right now. Try in a sec.`; } } async function fetchWeather() { const reply = await fetchWeatherReply(); addBubble(reply, false); speakReply(reply); } // ════════════════════════════════════════ //// ════════════════════════════════════════ // YAHOO MAIL BRIDGE — localhost:3849 // ════════════════════════════════════════ const MAIL = { base: 'http://localhost:3849', async count() { try { const r = await fetch(`${this.base}/mail/count`, {signal:AbortSignal.timeout(8000)}); return r.json(); } catch(e) { return null; } }, async unread() { try { const r = await fetch(`${this.base}/mail/unread`, {signal:AbortSignal.timeout(12000)}); return r.json(); } catch(e) { return null; } }, async inbox(limit=30) { try { const r = await fetch(`${this.base}/mail/inbox?limit=${limit}`, {signal:AbortSignal.timeout(12000)}); return r.json(); } catch(e) { return null; } }, async archive(uid) { try { const r = await fetch(`${this.base}/mail/archive`, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({uid}),signal:AbortSignal.timeout(10000)}); return r.json(); } catch(e) { return null; } }, async search(q) { try { const r = await fetch(`${this.base}/mail/search?q=${encodeURIComponent(q)}`, {signal:AbortSignal.timeout(10000)}); return r.json(); } catch(e) { return null; } } }; // Load Yahoo inbox and render as chat card async function checkYahooMail() { addBubble('Checking your Yahoo inbox…', false); const data = await MAIL.unread(); if (!data) { addBubble('Mail bridge is not running. Start it with: cd ~/workspace/mail-bridge && node server.js', false); return; } if (data.messages.length === 0) { addBubble('Yahoo inbox is clear — no unread messages. Nice.', false); return; } const rows = data.messages.slice(0,10).map(m => { const d = m.date ? new Date(m.date).toLocaleDateString([],{month:'short',day:'numeric'}) : ''; return `
${(m.fromName||'?')[0].toUpperCase()}
${esc(m.subject)}
${esc(m.fromName)} · ${d}
`; }).join(''); addBubble(`${data.count} unread in Yahoo — here are the newest:
${rows}
Refresh
Triage with Valet
`, false); } function handleMailAction(uid, subject, from) { const reply = `Email from ${from}: "${subject}"\n\nOptions:`; addBubble(`${esc(subject)}
From: ${esc(from)}
Archive
Draft reply
Ask Valet
`, false); } async function archiveMail(uid) { addBubble('Archiving…', false); const r = await MAIL.archive(uid); if (r?.ok) addBubble('Archived. ✓', false); else addBubble('Could not archive — check bridge is running.', false); } // Update header with live mail count async function updateMailBadge() { const data = await MAIL.count(); if (data && data.unread > 0) { const chip = document.getElementById('memChip'); if (chip) { chip.style.background = 'var(--red-l)'; chip.querySelector('span').textContent = `📬 ${data.unread} unread`; chip.style.color = 'var(--red)'; chip.onclick = () => { goTab('troy'); checkYahooMail(); }; } } } // ════════════════════════════════════════ // LOCATION + CLOCK + DRAWER + MODALS + INIT // ════════════════════════════════════════ let locStart=Date.now(),locDist=0,lastPt=null; function hav(a,b){const R=3958.8;const dL=(b.lat-a.lat)*Math.PI/180;const dN=(b.lng-a.lng)*Math.PI/180;const x=Math.sin(dL/2)**2+Math.cos(a.lat*Math.PI/180)*Math.cos(b.lat*Math.PI/180)*Math.sin(dN/2)**2;return R*2*Math.atan2(Math.sqrt(x),Math.sqrt(1-x));} if(navigator.geolocation){navigator.geolocation.watchPosition(p=>{const c={lat:p.coords.latitude,lng:p.coords.longitude};if(lastPt){const d=hav(lastPt,c);if(d<0.1)locDist+=d;}lastPt=c;const m=Math.round((Date.now()-locStart)/60000);document.getElementById('wDist').textContent=locDist.toFixed(1);document.getElementById('wSteps').textContent=Math.round(locDist*2000);document.getElementById('wActive').textContent=m+'m';},()=>{},{enableHighAccuracy:true,maximumAge:30000});} function doNav(){const d=prompt('Navigate to:','');if(d&&d.trim())window.open('https://maps.apple.com/?q='+encodeURIComponent(d),'_blank');} // ── Morning Check-In ──────────────────────────────────────────────────────── let _ciReadiness = 0; let _ciSleep = 7; function ciInit() { // Check if already logged today try { const saved = JSON.parse(localStorage.getItem('valet_biometrics') || 'null'); const today = new Date().toDateString(); if (saved && saved._ts && new Date(saved._ts).toDateString() === today && saved._src === 'checkin') { // Already logged — show badge const el = document.getElementById('morningLoggedBadge'); if (el) el.style.display = 'block'; const sum = document.getElementById('ciLoggedSummary'); if (sum) { const labels = ['','Rough','Tired','Okay','Good','Dialed']; sum.textContent = `${labels[saved.readiness] || 'Logged'} · ${saved.sleep}h sleep`; } return; } } catch(e){} // Show check-in — only in morning/afternoon (skip late night) const h = new Date().getHours(); if (h >= 4 && h < 22) { const el = document.getElementById('morningCheckin'); if (el) el.style.display = 'block'; } } function ciSetR(v) { _ciReadiness = v; document.querySelectorAll('.ci-btn').forEach(b => { const bv = parseInt(b.getAttribute('data-v')); if (bv === v) { b.style.background = 'var(--gold-g)'; b.style.color = '#fff'; b.style.borderColor = 'var(--gold)'; } else { b.style.background = 'none'; b.style.color = 'var(--t3)'; b.style.borderColor = 'var(--border2)'; } }); const btn = document.getElementById('ciSubmitBtn'); if (btn) { btn.style.opacity = '1'; btn.style.pointerEvents = 'auto'; } } function ciSleep(delta) { _ciSleep = Math.min(12, Math.max(0, _ciSleep + delta)); const el = document.getElementById('ciSleepVal'); if (el) el.textContent = _ciSleep % 1 === 0 ? `${_ciSleep}h` : `${_ciSleep}h`; } function ciSubmit() { if (!_ciReadiness) return; const data = { readiness: _ciReadiness, sleep: _ciSleep, hrv: null, rhr: null, steps: null, _ts: Date.now(), _src: 'checkin' }; localStorage.setItem('valet_biometrics', JSON.stringify(data)); // Hide card, show badge const card = document.getElementById('morningCheckin'); if (card) { card.style.opacity = '0'; card.style.transition = 'opacity 0.3s'; setTimeout(() => card.style.display = 'none', 300); } const badge = document.getElementById('morningLoggedBadge'); if (badge) { badge.style.display = 'block'; } const sum = document.getElementById('ciLoggedSummary'); if (sum) { const labels = ['','Rough','Tired','Okay','Good','Dialed']; sum.textContent = `${labels[_ciReadiness]} · ${_ciSleep}h sleep`; } showToast('Morning logged ✓'); // Prompt Troy with context const labels = ['','Rough','Tired','Okay','Good','Dialed']; const msg = `[Morning check-in] Readiness: ${labels[_ciReadiness]} (${_ciReadiness}/5), Sleep: ${_ciSleep}h`; // Silent store to session context — no chat bubble if (typeof sessionHistory !== 'undefined') { sessionHistory.push({ role:'user', content: msg }); } } function ciReset() { _ciReadiness = 0; _ciSleep = 7; localStorage.removeItem('valet_biometrics'); const badge = document.getElementById('morningLoggedBadge'); if (badge) badge.style.display = 'none'; const card = document.getElementById('morningCheckin'); if (card) { card.style.opacity = '1'; card.style.display = 'block'; } document.querySelectorAll('.ci-btn').forEach(b => { b.style.background = 'none'; b.style.color = 'var(--t3)'; b.style.borderColor = 'var(--border2)'; }); const btn = document.getElementById('ciSubmitBtn'); if (btn) { btn.style.opacity = '0.4'; btn.style.pointerEvents = 'none'; } const sv = document.getElementById('ciSleepVal'); if (sv) sv.textContent = '7h'; } // ════════════════════════════════════════ // GOOGLE PLACES — Nearby Search // ════════════════════════════════════════ async function findNearby(type) { const label = type === 'restaurant' ? 'restaurants' : 'gas stations'; const el = document.getElementById('placesResults'); el.innerHTML = `
Finding ${label} near ${VALET_CONFIG.clientLocation}...
`; try { // Use Places Text Search via proxy-friendly URL const query = encodeURIComponent(`${label} near Eagle Idaho`); const url = `https://maps.googleapis.com/maps/api/place/textsearch/json?query=${query}&key=${GOOGLE_KEY}`; // Places API has CORS restrictions; open in Google Maps instead window.open(`https://www.google.com/maps/search/${query}/@43.6955,-116.3535,13z`, '_blank'); el.innerHTML = ''; } catch(e) { el.innerHTML = ''; } } async function searchPlaces(q) { if (!q || !q.trim()) return; const el = document.getElementById('placesResults'); el.innerHTML = `
Searching for "${q}" near Eagle...
`; // Places API requires server-side proxy for CORS; open in Google Maps const query = encodeURIComponent(`${q} near Eagle Idaho`); setTimeout(() => { window.open(`https://www.google.com/maps/search/${query}/@43.6955,-116.3535,13z`, '_blank'); el.innerHTML = `
Opened Google Maps for "${q}" near Eagle
`; }, 300); } // ════════════════════════════════════════ // YOUTUBE — Yoga Video Search // ════════════════════════════════════════ async function searchYogaVideos() { const el = document.getElementById('ytResults'); el.innerHTML = '
Loading yoga videos...
'; try { const q = encodeURIComponent('yoga for beginners morning 10 minutes'); const url = `https://www.googleapis.com/youtube/v3/search?part=snippet&q=${q}&type=video&videoCategoryId=17&maxResults=4&relevanceLanguage=en&key=${GOOGLE_KEY}`; const r = await fetch(url, {signal: AbortSignal.timeout(8000)}); if (!r.ok) throw new Error('YouTube API error'); const d = await r.json(); if (!d.items || !d.items.length) throw new Error('No results'); el.innerHTML = d.items.map(v => `
${v.snippet.title.substring(0,55)}${v.snippet.title.length>55?'…':''}
${v.snippet.channelTitle}
`).join(''); } catch(e) { el.innerHTML = `
Couldn't load videos. Open YouTube →
`; } } function openYTVideo(vid, title) { // Open in YouTube window.open(`https://www.youtube.com/watch?v=${vid}`, '_blank'); } // ════════════════════════════════════════ // MULTI-MODEL AI ENGINE // GPT-4o · Grok · Perplexity · Gemini // Smart routing by query type // ════════════════════════════════════════ function routeModel(txt) { const l = (txt||'').toLowerCase(); // Needs live/current web data → Perplexity sonar if (/\b(who won|latest news|breaking|right now|score|standings|current events|what happened|today in|this week in|2025|2026)\b/.test(l)) return 'perplexity'; // Everything else → GPT-4o (personal context, better conversational voice) return 'openai'; } async function askAI(prompt, model = 'openai') { if (model === 'gemini') return askGemini(prompt); if (model === 'perplexity') return askPerplexity(prompt); // OpenAI — use mini for dialog (faster first token), full 4o for text/complex queries const userMsg = prompt.includes('User:') ? prompt.split('User:').pop().trim() : prompt; const oaiModel = 'gpt-4o'; // always GPT-4o — mini produces broken grammar in voice mode const maxTok = dialogMode ? 120 : 400; // short = fast = more conversational in voice mode const res = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: oaiModel, messages: [ { role: 'system', content: buildCtx() }, ...sessionHistory.slice(-SESSION_HISTORY_MAX) ], max_tokens: maxTok, temperature: 0.75 }), signal: AbortSignal.timeout(20000) }); if (!res.ok) { const err = await res.text(); throw new Error(`OpenAI ${res.status}: ${err.slice(0,100)}`); } const d = await res.json(); const text = d.choices?.[0]?.message?.content?.trim() || null; if (text) window._lastModel = 'GPT-4o'; return text; } async function askAIStream(prompt, onChunk, onDone, onError) { const userMsg = prompt.includes('User:') ? prompt.split('User:').pop().trim() : prompt; const streamModel = 'gpt-4o'; // always GPT-4o — mini produces broken grammar in voice mode const streamMaxTok = dialogMode ? 120 : 400; try { const res = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: streamModel, messages: [ { role: 'system', content: buildCtx() }, ...sessionHistory.slice(-SESSION_HISTORY_MAX) ], max_tokens: streamMaxTok, temperature: 0.75, stream: true }), signal: AbortSignal.timeout(25000) }); if (!res.ok) throw new Error('OpenAI stream ' + res.status); const reader = res.body.getReader(); const decoder = new TextDecoder(); let full = ''; while (true) { const { done, value } = await reader.read(); if (done) break; const lines = decoder.decode(value).split('\n').filter(l => l.startsWith('data: ') && !l.includes('[DONE]')); for (const line of lines) { try { const delta = JSON.parse(line.slice(6)).choices?.[0]?.delta?.content; if (delta) { full += delta; onChunk(full); } } catch(e) {} } } window._lastModel = 'GPT-4o'; onDone(full); } catch(e) { onError(e); } } async function askPerplexity(prompt) { // Routes through server proxy — PPLX_KEY never exposed client-side const userMsg = prompt.includes('User:') ? prompt.split('User:').pop().trim() : prompt; try { const res = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'perplexity', messages: [ { role: 'system', content: buildCtx() }, { role: 'user', content: userMsg } ] }), signal: AbortSignal.timeout(20000) }); if (!res.ok) throw new Error('Perplexity proxy ' + res.status); const d = await res.json(); const text = d.choices?.[0]?.message?.content?.trim() || null; if (text) window._lastModel = 'Perplexity'; return text; } catch(e) { console.warn('Perplexity failed, falling back:', e.message); return askAI(prompt, 'openai'); } } async function askGemini(prompt) { // Routes through server proxy — GEMINI_KEY never exposed client-side try { const res = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'gemini', messages: [{ role: 'user', content: prompt }] }), signal: AbortSignal.timeout(15000) }); if (!res.ok) throw new Error('Gemini proxy ' + res.status); const d = await res.json(); window._lastModel = 'Gemini'; return d.choices?.[0]?.message?.content?.trim() || null; } catch(e) { throw new Error('Gemini failed: ' + e.message); } } function updateClock(){ const n=new Date(); const ts=n.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}); const ds=n.toLocaleDateString([],{weekday:'short',month:'short',day:'numeric'}); ['clock1','clock2'].forEach(id=>{const el=document.getElementById(id);if(el)el.textContent=ts;}); const dc=document.getElementById('clockDate');if(dc)dc.textContent=ds; const h=n.getHours(); const gr=h<5?'Late night, Steve':h<12?'Morning, Steve':h<17?'Afternoon, Steve':'Evening, Steve'; const ts2=document.getElementById('troyGreet');if(ts2)ts2.textContent=gr; const st=document.getElementById('troyStatus');if(st)st.textContent=`Active · ${ts}`; // Update editorial hero const days=['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday']; const hd=document.getElementById('heroDay');if(hd)hd.textContent=days[n.getDay()]; const hdl=document.getElementById('heroDateLine');if(hdl)hdl.textContent=n.toLocaleDateString([],{month:'long',day:'numeric',year:'numeric'}).toUpperCase(); // Update masthead const md=document.getElementById('mastheadDate'); if(md)md.textContent=`${days[n.getDay()].toUpperCase()} · ${n.toLocaleDateString([],{month:'long',day:'numeric'})} · Boise, Idaho`; } updateClock();setInterval(updateClock,15000); function openDrawer(){document.getElementById('drwOv').classList.add('show');document.getElementById('drw').classList.add('show');} function closeDrawer(){document.getElementById('drwOv').classList.remove('show');document.getElementById('drw').classList.remove('show');} function openModal(id){document.getElementById(id).classList.add('show');} function closeModal(id){document.getElementById(id).classList.remove('show');} function initChat(){ const brief=buildDailyBrief().replace(/\n/g,'
'); const cList=buildProactiveChips().map(c=>`
${c.l}
`).join(''); addBubble(`${brief}
${cList}
`,false); // Sunday digest card if(new Date().getDay()===0&&new Date().getHours()>=17){ const sd=document.getElementById('msgs'); if(sd){const dc=document.createElement('div');dc.style.cssText='border:1.5px solid var(--gold);border-radius:14px;padding:12px 14px;margin:4px 0;background:var(--gold-ll);'; dc.innerHTML=`
Sunday Digest
Your week in review — what happened, what's next, what matters.
`; sd.appendChild(dc);sd.scrollTop=sd.scrollHeight;} } } // Init Google auth as soon as GIS is available function tryInitGoogle(attempts) { if (typeof google !== 'undefined' && google.accounts) { initGoogleAuth(); return; } if (attempts > 0) setTimeout(() => tryInitGoogle(attempts - 1), 800); } document.addEventListener('DOMContentLoaded', async () => { // ── PIN Auth gate ───────────────────────────────────────────────────────── if (!valetCheckAuth()) { document.getElementById('pinScreen').classList.add('active'); // Hide load screen — don't show the app behind the PIN screen const ls = document.getElementById('loadScreen'); if (ls) ls.style.display = 'none'; } // ── RingConn / Apple Health biometric ingestion via URL params ────────── // iOS Shortcut opens: https://troy-ai-portal.vercel.app?health={"hrv":45,...} try { const _hp = new URLSearchParams(window.location.search).get('health'); if (_hp) { const _hd = JSON.parse(decodeURIComponent(_hp)); _hd._ts = Date.now(); localStorage.setItem('valet_biometrics', JSON.stringify(_hd)); // Clean URL so it doesn't re-trigger on refresh window.history.replaceState({}, '', window.location.pathname); showToast('Biometrics updated ✓'); } } catch(_e) {} // Early token restore — set gToken from localStorage BEFORE GIS loads. // This ensures calendar context is cached and AI has live calendar data // even if the Google Identity Services script takes time to initialize. try { const _savedToken = JSON.parse(localStorage.getItem('goog_token') || 'null'); if (_savedToken && _savedToken.expiry > Date.now() + 60000) { gToken = _savedToken.token; gTokenExpiry = _savedToken.expiry; // Cache calendar context early so buildCtx() has live data from the start setTimeout(cacheCalendarContext, 400); } } catch(_e) {} // Loading screen reveal const ls=document.getElementById('loadScreen'); const lm=document.getElementById('loadMark'); const ln=document.getElementById('loadName'); const lsb=document.getElementById('loadSub'); if(lm){setTimeout(()=>{lm.style.opacity='1';lm.style.transform='translateY(0)';},100);} if(ln){setTimeout(()=>{ln.style.opacity='1';},400);} if(lsb){setTimeout(()=>{lsb.style.opacity='1';},600);} if(ls){setTimeout(()=>{ls.style.opacity='0';setTimeout(()=>{ls.style.display='none';},900);},1600);} await Mem.init(); ciInit(); journalInit(); initSettings(); initVoice(); setTimeout(() => tryInitGoogle(10), 500); // try up to 10 times over 8s updateClock(); initChat(); // Live data fetches (staggered to not hammer on load) setTimeout(fetchLiveStocks, 2500); // Live stocks + crypto setTimeout(fetchLiveNews, 3500); // Live news setInterval(fetchLiveStocks, 5 * 60 * 1000); // Refresh stocks every 5min // Initialize calendar renderCalendar(); // Scroll-reveal on initial tab setTimeout(() => triggerReveals(document.getElementById('p-troy')), 150); // Check Yahoo mail badge setTimeout(updateMailBadge, 2000); // Fetch weather after a moment setTimeout(async () => { try { const r = await fetch('https://wttr.in/${encodeURIComponent(VALET_CONFIG.clientLocation)}?format=j1', {signal: AbortSignal.timeout(8000)}); const d = await r.json(); const t = d.current_condition?.[0]?.temp_F; const desc = d.current_condition?.[0]?.weatherDesc?.[0]?.value || ''; if (t) { document.getElementById('inlineTemp').textContent = t + '°F ' + desc; document.getElementById('heroWeather').textContent = `${VALET_CONFIG.clientLocation} · ${t}°F`; document.getElementById('wxTemp').textContent = t + '°F'; document.getElementById('wxCond').textContent = desc; } } catch(e) { const el=document.getElementById('inlineTemp');if(el)el.textContent='62°F'; } }, 1200); // Init Life tab features initLifeFeatures(); // Boot Valet Schedule Engine (3s delay to let Mem + Google auth settle) setTimeout(bootValetEngine, 3000); }); // ════════════════════════════════════════ // PEOPLE TRACKER // ════════════════════════════════════════ const PEOPLE = [ {name:'Laura LaForte',rel:'Wife · MSW',av:'LL',color:'#B8962E',bg:'#FFFBEB',days:0,pending:'',phone:'',prompt:"I want to do something genuinely thoughtful for Laura today. She is an MSW with her own private practice. What would she actually appreciate?"}, {name:'Chase Gunderson',rel:'EVP Operations · Cascadia',av:'CG',color:'#2563EB',days:3,pending:'PDPM case mix review pending',phone:'',prompt:"What should I prep for my next conversation with Chase about PDPM case mix performance?"}, {name:'Owen Hammond',rel:'CEO · Business Partner',av:'OH',color:'#B8962E',days:0,pending:'',phone:'',prompt:"Draft a quick update to Owen on what I have on my plate this week."}, ]; function renderPeopleList(){ const el=document.getElementById('peopleList');if(!el)return; el.innerHTML=PEOPLE.map(p=>{ const d=p.days; const [bc,bt]=d>=14?['badge-red',d+'d ago']:d>=7?['badge-amber',d+'d ago']:['badge-green','Recent']; // Check Google Contacts for phone if not hardcoded let phone = p.phone || ''; if (!phone) { const contacts = JSON.parse(localStorage.getItem('goog_contacts')||'[]'); const match = contacts.find(c => c.name.toLowerCase().includes(p.name.split(' ')[0].toLowerCase())); if (match && match.phone) phone = match.phone.replace(/\D/g,''); } const smsBtn = phone ? `Text` : ''; return `
${p.av}
${p.name}
${p.rel}
${p.pending}
${smsBtn}
${bt}
`; }).join(''); } // ════════════════════════════════════════ // ════════════════════════════════════════ // JOURNAL SYSTEM // ════════════════════════════════════════ const JOURNAL_TYPES = { memoir: { label:'Memoir', badge:'#B8962E' }, daily: { label:'Daily', badge:'#4A9B7F' }, work: { label:'Work', badge:'#3B7DD8' }, ideas: { label:'Ideas', badge:'#D97706' }, family: { label:'Family', badge:'#C4887A' }, notes: { label:'Notes', badge:'#7A7A8E' }, }; let _jActiveId = null; // currently open journal id function journalGetMeta() { try { return JSON.parse(localStorage.getItem('valet_journals') || 'null') || { journals:[], activeId:null }; } catch(e) { return { journals:[], activeId:null }; } } function journalSaveMeta(meta) { localStorage.setItem('valet_journals', JSON.stringify(meta)); } function journalGetEntries(jid) { try { return JSON.parse(localStorage.getItem('valet_journal_' + jid) || '{"entries":[]}').entries || []; } catch(e) { return []; } } function journalSaveEntries(jid, entries) { localStorage.setItem('valet_journal_' + jid, JSON.stringify({ entries })); } function journalInit() { let meta = journalGetMeta(); // First-time: migrate legacy memoir_entries + create default journal if (!meta.journals.length) { const id = 'j_' + Date.now(); meta.journals = []; meta.activeId = id; // Migrate old memoir entries try { const old = JSON.parse(localStorage.getItem('memoir_entries') || '[]'); if (old.length) journalSaveEntries(id, old.map(e => ({ id:'e_'+e.ts, text:e.text, ts:e.ts||Date.now() }))); } catch(e){} journalSaveMeta(meta); } journalRenderList(); } function journalView(view) { ['list','detail','write'].forEach(v => { const el = document.getElementById('jv-'+v); if (el) el.style.display = v === view ? 'block' : 'none'; }); if (view === 'list') journalRenderList(); if (view === 'detail') journalRenderDetail(); if (view === 'write') { const meta = journalGetMeta(); const j = meta.journals.find(j => j.id === _jActiveId); const name = document.getElementById('jvWriteName'); if (name && j) name.textContent = j.name; const dateEl = document.getElementById('jvWriteDate'); if (dateEl) dateEl.textContent = new Date().toLocaleDateString('en-US',{month:'short',day:'numeric'}); const inp = document.getElementById('journalInput'); if (inp) { inp.value=''; inp.style.height='auto'; setTimeout(()=>inp.focus(),150); } } } function journalRenderList() { const wrap = document.getElementById('journalListWrap'); if (!wrap) return; const meta = journalGetMeta(); const active = meta.journals.filter(j => !j.archived); const archived = meta.journals.filter(j => j.archived); let html = ''; if (!active.length) { html = '
No journals yet. Tap + New to start.
'; } active.forEach(j => { const entries = journalGetEntries(j.id); const lastEntry = entries[0]; const lastTime = lastEntry ? journalRelTime(lastEntry.ts) : 'Empty'; const typeInfo = JOURNAL_TYPES[j.type] || JOURNAL_TYPES.notes; html += `
${j.name}
${typeInfo.label} · ${entries.length} ${entries.length===1?'entry':'entries'} · ${lastTime}
`; }); if (archived.length) { html += `
Archived (${archived.length})
'; } wrap.innerHTML = html; } function toggleArchivedJournals() { const list = document.getElementById('archivedList'); const chev = document.getElementById('archivedChevron'); if (!list) return; const open = list.style.display !== 'none'; list.style.display = open ? 'none' : 'block'; if (chev) chev.style.transform = open ? '' : 'rotate(180deg)'; } function journalOpen(jid) { _jActiveId = jid; journalView('detail'); } function journalRenderDetail() { const meta = journalGetMeta(); const j = meta.journals.find(j => j.id === _jActiveId); if (!j) { journalView('list'); return; } const nameEl = document.getElementById('jvDetailName'); if (nameEl) nameEl.textContent = j.name; const wrap = document.getElementById('journalEntriesWrap'); if (!wrap) return; const entries = journalGetEntries(_jActiveId); if (!entries.length) { wrap.innerHTML = `
No entries yet — tap Write to start.
`; return; } let html = ''; entries.forEach((e, idx) => { const d = new Date(e.ts); const dateStr = d.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'}); const timeStr = d.toLocaleTimeString('en-US',{hour:'numeric',minute:'2-digit'}); const preview = e.text.length > 160 ? e.text.slice(0,160)+'…' : e.text; html += `
${dateStr} · ${timeStr}
${preview}
`; }); html += `
`; wrap.innerHTML = html; } function journalSaveEntry() { const inp = document.getElementById('journalInput'); const text = (inp ? inp.value : '').trim(); if (!text) return; const entries = journalGetEntries(_jActiveId); entries.unshift({ id:'e_'+Date.now(), text, ts:Date.now() }); journalSaveEntries(_jActiveId, entries.slice(0, 200)); profileAddSignal('journal', { jid:_jActiveId, text, ts:Date.now() }); if (inp) { inp.value=''; inp.style.height='auto'; } journalView('detail'); showToast('Saved ✓'); journalSpeakAck(text); } async function journalSendToTroy(jid, idx) { const entries = journalGetEntries(jid); const e = entries[parseInt(idx)]; if (!e) return; const meta = journalGetMeta(); const j = meta.journals.find(j => j.id === jid); const jname = j ? j.name : 'journal'; // Show immediate feedback showToast('Troy is reading this…'); try { const userMsg = `I just wrote this in my ${jname} journal: "${e.text}" — what do you see in it? Keep it short and conversational.`; const r = await fetch('/api/chat', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ model:'gpt-4o', messages:[ { role:'system', content: buildCtx() }, { role:'user', content: userMsg } ], max_tokens:200 }) }); const d = await r.json(); const reply = d.choices?.[0]?.message?.content?.trim() || ''; if (reply) { // Add to session history so it's part of the conversation if (typeof sessionHistory !== 'undefined') { sessionHistory.push({ role:'user', content: userMsg }); sessionHistory.push({ role:'assistant', content: reply }); } // Speak the response speakReply(reply); // Show in chat tab goTab('troy'); setTimeout(() => { addBubble(`[${jname} journal]
${e.text.slice(0,80)}${e.text.length>80?'…':''}`, true); addBubble(reply, false); }, 200); } } catch(err) { // Fallback to simple chat goTab('troy'); setTimeout(() => chat(`From my ${jname} journal: "${e.text.slice(0,120)}" — what do you see in this?`), 200); } } function journalDeleteEntry(jid, idx) { const entries = journalGetEntries(jid); entries.splice(parseInt(idx), 1); journalSaveEntries(jid, entries); journalRenderDetail(); } function journalNew() { const name = prompt('Journal name:', 'Personal'); if (!name || !name.trim()) return; const types = Object.keys(JOURNAL_TYPES); const typeInput = prompt('Type (memoir, daily, work, ideas, family, notes):', 'daily'); const type = types.includes((typeInput||'').toLowerCase()) ? typeInput.toLowerCase() : 'notes'; const meta = journalGetMeta(); const id = 'j_' + Date.now(); meta.journals.push({ id, name:name.trim(), type, createdAt:Date.now(), archived:false }); journalSaveMeta(meta); _jActiveId = id; journalView('detail'); showToast('Journal created ✓'); } function journalArchive(jid) { if (!confirm('Archive this journal? You can restore it anytime.')) return; const meta = journalGetMeta(); const j = meta.journals.find(j => j.id === jid); if (j) j.archived = true; journalSaveMeta(meta); journalView('list'); showToast('Archived'); } function journalUnarchive(jid) { const meta = journalGetMeta(); const j = meta.journals.find(j => j.id === jid); if (j) j.archived = false; journalSaveMeta(meta); journalRenderList(); showToast('Restored ✓'); } async function journalVoiceCapture() { const btn = document.getElementById('jvVoiceBtn'); const inp = document.getElementById('journalInput'); if (!inp) return; const btnRestore = () => { if(btn){ btn.innerHTML='Voice'; btn.style.color='var(--t2)'; btn.onclick=journalVoiceCapture; }}; if (btn) { btn.textContent = '● Tap to stop'; btn.style.color='var(--red)'; } try { const stream = await navigator.mediaDevices.getUserMedia({ audio:true }); const mr = new MediaRecorder(stream); const chunks = []; mr.ondataavailable = e => { if(e.data.size > 0) chunks.push(e.data); }; mr.onstop = async () => { stream.getTracks().forEach(t => t.stop()); if (btn) { btn.textContent = '⟳ Processing…'; btn.style.color='var(--t4)'; } try { const blob = new Blob(chunks, { type:'audio/mp4' }); const fd = new FormData(); fd.append('file', blob, 'entry.m4a'); fd.append('model','whisper-1'); // Give Whisper context so it doesn't drop words const meta = journalGetMeta(); const j = meta.journals.find(j => j.id === _jActiveId); fd.append('prompt', `Personal journal entry${j?` for "${j.name}"`:''}, thoughtful and conversational. Complete sentences.`); const r = await fetch('/api/transcribe', { method:'POST', body:fd }); const d = await r.json(); if (d.text) { // Clean up fragmented transcription with GPT const cleaned = await journalCleanTranscript(d.text); inp.value = (inp.value ? inp.value + '\n' : '') + cleaned; inp.style.height='auto'; inp.style.height=inp.scrollHeight+'px'; } } catch(e) { showToast('Voice capture failed'); } btnRestore(); }; mr.start(); setTimeout(() => { try{mr.stop();}catch(e){} }, 30000); // 30s max if (btn) { btn.onclick = () => { try{mr.stop();}catch(e){} }; } } catch(e) { btnRestore(); showToast('Microphone not available'); } } async function journalCleanTranscript(raw) { if (!raw || raw.length < 10) return raw; try { const r = await fetch('/api/chat', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ model:'gpt-4o', messages:[ {role:'system', content:'Clean up this voice-to-text journal transcript. Fix dropped words, punctuation, and sentence fragments. Preserve the speaker\'s authentic voice and original meaning exactly. Return only the corrected text, nothing else.'}, {role:'user', content:raw} ], max_tokens: 600 }) }); const d = await r.json(); return d.choices?.[0]?.message?.content?.trim() || raw; } catch(e) { return raw; } } function journalSpeakAck(text) { // Brief spoken acknowledgment via ElevenLabs after saving a journal entry const short = text.length < 80; const acks = short ? ["Saved.", "Got it.", "Logged that."] : ["Saved that.", "Got it. Good one.", "Logged."]; const ack = acks[Math.floor(Math.random() * acks.length)]; speakReply(ack); } // ── Client Profile Mapping ──────────────────────────────────────────────── // Everything in the app feeds this profile layer which informs Troy's context. function profileAddSignal(type, data) { // Lightweight — just let buildCtx() pull from live data sources // This is a hook for future signal aggregation try { const signals = JSON.parse(localStorage.getItem('valet_profile_signals') || '[]'); signals.unshift({ type, data, ts:Date.now() }); localStorage.setItem('valet_profile_signals', JSON.stringify(signals.slice(0,100))); } catch(e){} } function buildProfileContext() { try { const lines = []; // Journal entries (last 5 across all active journals) const meta = journalGetMeta(); const allEntries = []; (meta.journals||[]).filter(j=>!j.archived).forEach(j => { journalGetEntries(j.id).slice(0,3).forEach(e => { allEntries.push({ journal:j.name, type:j.type, text:e.text, ts:e.ts }); }); }); allEntries.sort((a,b)=>b.ts-a.ts); const recentJournals = allEntries.slice(0,4); if (recentJournals.length) { lines.push('JOURNAL ENTRIES:'); recentJournals.forEach(e => { const d = journalRelTime(e.ts); const preview = e.text.slice(0,120).replace(/\n/g,' '); lines.push(` - [${e.journal}] ${d}: "${preview}${e.text.length>120?'…':''}"`); }); } // Decision log try { const decisions = JSON.parse(localStorage.getItem('decision_log')||'[]').slice(0,3); if (decisions.length) { lines.push('RECENT DECISIONS:'); decisions.forEach(d => lines.push(` - ${d.what}${d.why?' ('+d.why+')':''}`)); } } catch(e){} // Morning check-ins (last 7 days pattern) try { const bio = JSON.parse(localStorage.getItem('valet_biometrics')||'null'); if (bio && bio._src==='checkin' && bio._ts) { const rlabels = ['','Rough','Tired','Okay','Good','Dialed']; lines.push(`TODAY'S STATE: ${rlabels[bio.readiness]||bio.readiness} energy · ${bio.sleep}h sleep`); } } catch(e){} // Connections / accounts try { const qa = []; CONNECT_SERVICES.filter(s=>s.type==='question').forEach(s=>{ const v = localStorage.getItem('valet_qa_'+s.id); if(v){ const a=JSON.parse(v).answer; if(a) qa.push(`${s.name}: ${a}`); } }); if(qa.length) lines.push('KNOWN ACCOUNTS: ' + qa.join(' | ')); } catch(e){} return lines.length ? lines.join('\n') : ''; } catch(e) { return ''; } } function journalRelTime(ts) { const diff = Date.now() - ts; const h = Math.floor(diff/3600000); if (h < 1) return 'just now'; if (h < 24) return h+'h ago'; const d = Math.floor(h/24); if (d < 7) return d+'d ago'; return new Date(ts).toLocaleDateString('en-US',{month:'short',day:'numeric'}); } // Stubs for backward compat function saveMemoirEntry(){ journalSaveEntry(); } function renderMemoirEntries(){} function voiceMemoirCapture(){ journalVoiceCapture(); } // ════════════════════════════════════════ // DECISION LOG // ════════════════════════════════════════ function saveDecision(){ const what=document.getElementById('decisionWhat').value.trim(); const why=document.getElementById('decisionWhy').value.trim(); if(!what)return; const log=JSON.parse(localStorage.getItem('decision_log')||'[]'); log.unshift({what,why,date:new Date().toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'}),ts:Date.now()}); localStorage.setItem('decision_log',JSON.stringify(log.slice(0,100))); document.getElementById('decisionWhat').value=''; document.getElementById('decisionWhy').value=''; renderDecisions(); } function renderDecisions(){ const el=document.getElementById('decisionList');if(!el)return; const log=JSON.parse(localStorage.getItem('decision_log')||'[]'); if(!log.length){el.innerHTML='
No decisions logged yet.
';return;} el.innerHTML=log.slice(0,5).map(d=>`
${d.what}
${d.why?`
Because: ${d.why}
`:''}
${d.date}
`).join(''); } // ════════════════════════════════════════ // MORNING CHECK-IN // ════════════════════════════════════════ function renderCheckin(){ const el=document.getElementById('energyBtns');if(!el)return; const today=new Date().toLocaleDateString(); const saved=JSON.parse(localStorage.getItem('checkin_log')||'[]'); const todayEntry=saved.find(e=>e.date===today); el.innerHTML=Array.from({length:10},(_,i)=>i+1).map(n=>``).join(''); const hist=document.getElementById('checkinHistory'); const dt=document.getElementById('checkinDate'); if(dt)dt.textContent=new Date().toLocaleDateString('en-US',{weekday:'long',month:'short',day:'numeric'}); if(hist&&saved.length>1){ const avg=(saved.slice(0,7).reduce((a,b)=>a+b.energy,0)/Math.min(saved.length,7)).toFixed(1); hist.textContent=`7-day avg: ${avg}/10 · ${saved.length} check-ins logged`; } } function logEnergy(n){ const today=new Date().toLocaleDateString(); const saved=JSON.parse(localStorage.getItem('checkin_log')||'[]'); const filtered=saved.filter(e=>e.date!==today); filtered.unshift({date:today,energy:n,ts:Date.now()}); localStorage.setItem('checkin_log',JSON.stringify(filtered.slice(0,90))); renderCheckin(); const msg=n<=4?`My energy is ${n}/10 today. What's one small thing that would actually help?`:n>=8?`Feeling strong — ${n}/10. What's the highest-leverage thing I should do with this energy right now?`:`Energy at ${n}/10 — solid. What should I prioritize today?`; setTimeout(()=>{chat(msg);goTab('troy');},300); } // ════════════════════════════════════════ // INTEL FEED FILTER // ════════════════════════════════════════ function filterIntel(type){ document.querySelectorAll('.intel-tag').forEach(t=>t.classList.remove('active')); const tag=document.getElementById('itag-'+type);if(tag)tag.classList.add('active'); document.querySelectorAll('#intelFeed .intel-item').forEach(item=>{ if(type==='all'){item.style.display='';return;} const cat=item.querySelector('.intel-cat'); item.style.display=(cat&&cat.classList.contains(type))?'':'none'; }); } // ════════════════════════════════════════ // INIT ALL NEW FEATURES // ════════════════════════════════════════ function initLifeFeatures(){ renderPeopleList(); renderMemoirEntries(); renderDecisions(); renderCheckin(); } // ════════════════════════════════════════ // AMBIENT LISTEN ENGINE // Free tier: Web Speech API (built-in) // Premium: Deepgram real-time WebSocket // ════════════════════════════════════════ const DEEPGRAM_KEY = ''; // Paste Deepgram API key here for premium quality (~$0.004/min) let ambientOn = false; let ambientRecog = null; let ambientBuffer = []; let ambientProcessTimer = null; let ambientMediaRecorder = null; let ambientDgSocket = null; let ambientStream = null; let ambientInsightCount = 0; function toggleAmbient() { if(ambientOn) stopAmbient(); else startAmbient(); } async function startAmbient() { ambientOn = true; const btn = document.getElementById('ambientBtn'); const lbl = document.getElementById('ambientBtnLbl'); const dot = document.getElementById('ambientDot'); if(btn) btn.classList.add('on'); if(lbl) lbl.textContent = 'Live'; if(dot) { dot.style.background = '#ef4444'; dot.style.boxShadow = '0 0 6px #ef4444'; dot.style.animation = 'goldPulse 1.2s infinite'; } try { // Reuse dialog stream if available (don't double-request permission on iOS) ambientStream = (typeof dialogStream !== 'undefined' && dialogStream) ? dialogStream : await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } }); // NOTE: We don't start a 30s MediaRecorder here — wake polling runs its own // 4s clips on the stream. Two MediaRecorders on the same stream breaks iOS. // Ambient context analysis runs during wake poll gaps (future enhancement). // Unlock iOS audio session so Audio.play() works without user gesture later try { var silentCtx = new (window.AudioContext || window.webkitAudioContext)(); var silentBuf = silentCtx.createBuffer(1, 1, 22050); var silentSrc = silentCtx.createBufferSource(); silentSrc.buffer = silentBuf; silentSrc.connect(silentCtx.destination); silentSrc.start(0); setTimeout(function(){ try{silentCtx.close();}catch(e){} }, 500); } catch(e) {} // Start wake word detection setTimeout(startWakeWord, 800); } catch(err) { ambientOn = false; if(btn) btn.classList.remove('on'); if(lbl) lbl.textContent = 'Listen'; if(dot) { dot.style.background = 'var(--t5)'; dot.style.boxShadow = ''; dot.style.animation = ''; } // Don't show error — just silently disable } } function stopAmbient() { ambientOn = false; const btn = document.getElementById('ambientBtn'); const lbl = document.getElementById('ambientBtnLbl'); const dot = document.getElementById('ambientDot'); if(btn) btn.classList.remove('on'); if(lbl) lbl.textContent = 'Listen'; if(dot) { dot.style.background = 'var(--t5)'; dot.style.boxShadow = ''; dot.style.animation = ''; } if(ambientMediaRecorder){try{ambientMediaRecorder.stop();}catch(e){}ambientMediaRecorder=null;} if(ambientDgSocket){ambientDgSocket.close();ambientDgSocket=null;} // Only stop stream if not shared with dialog if(ambientStream && ambientStream !== (typeof dialogStream !== 'undefined' ? dialogStream : null)){ ambientStream.getTracks().forEach(t=>t.stop()); ambientStream=null; } if(ambientRecog){try{ambientRecog.stop();}catch(e){}ambientRecog=null;} if(ambientProcessTimer){clearTimeout(ambientProcessTimer);} stopWakeWord(); } // ── Legacy stubs (replaced by MediaRecorder ambient) ── function startWebSpeechAmbient() { /* replaced */ } async function startDeepgramListen() { /* replaced */ } // ══════════════════════════════════════════════════════════════════ // WAKE WORD ENGINE — "Hey Troy" // Whisper-based 4s polling loop — no SpeechRecognition (iOS-safe) // MediaRecorder + SpeechRecognition can't coexist on iOS; Whisper wins // ══════════════════════════════════════════════════════════════════ let wakeWordActive = false; let wakeHandled = false; let wakePollTimer = null; const WAKE_PHRASES = [ 'hey valet', 'hey, valet', 'heyvalet', 'hey ' + WAKE_NAME, 'hey, ' + WAKE_NAME, 'hey' + WAKE_NAME, 'a ' + WAKE_NAME, ]; function startWakeWord() { if (wakeWordActive) return; wakeWordActive = true; wakeHandled = false; var lbl = document.getElementById('ambientBtnLbl'); if (lbl) lbl.textContent = `"Hey ${AGENT_NAME}"`; setTimeout(runWakePoll, 300); } function stopWakeWord() { wakeWordActive = false; clearTimeout(wakePollTimer); var lbl = document.getElementById('ambientBtnLbl'); if (lbl && (lbl.textContent.includes(AGENT_NAME) || lbl.textContent === '"Hey Troy"')) lbl.textContent = 'Listen'; } async function runWakePoll() { if (!wakeWordActive || !ambientOn || micActive || wakeHandled) return; var stream = ambientStream; if (!stream) { wakePollTimer = setTimeout(runWakePoll, 1000); return; } // Pick best supported MIME type var mimeType = ''; var candidates = ['audio/mp4','audio/webm;codecs=opus','audio/webm','audio/ogg']; for (var i = 0; i < candidates.length; i++) { try { if (MediaRecorder.isTypeSupported(candidates[i])) { mimeType = candidates[i]; break; } } catch(e) {} } var chunks = []; var rec; try { rec = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream); } catch(e) { wakePollTimer = setTimeout(runWakePoll, 2000); return; } mimeType = rec.mimeType || mimeType; rec.ondataavailable = function(e) { if (e.data && e.data.size > 0) chunks.push(e.data); }; await new Promise(function(resolve) { rec.onstop = resolve; rec.onerror = resolve; try { rec.start(); } catch(e) { resolve(); return; } // 3s window — long enough to say "Hey Troy", short enough to feel responsive setTimeout(function() { if (rec.state === 'recording') { try { rec.stop(); } catch(e) { resolve(); } } }, 3500); }); if (!wakeWordActive || wakeHandled || micActive) return; var blob = new Blob(chunks, { type: mimeType }); if (blob.size > 500) { try { var ext = mimeType.includes('mp4') ? 'm4a' : 'webm'; var fd = new FormData(); fd.append('file', blob, 'wake.' + ext); fd.append('model', 'whisper-1'); fd.append('language', 'en'); var res = await fetch('/api/transcribe', { method:'POST', body:fd, signal:AbortSignal.timeout(10000) }); if (res.ok) { var data = await res.json(); var t = (data.text || '').toLowerCase().replace(/[,\.!?]/g,'').trim(); if (!wakeHandled && WAKE_PHRASES.some(function(p){ return t.includes(p); })) { wakeHandled = true; handleWakeWord(); return; } } } catch(e) { /* timeout — continue */ } } // Continue polling wakePollTimer = setTimeout(runWakePoll, 200); } function handleWakeWord() { if (micActive) return; // already in dialog // Stop wake polling during dialog clearTimeout(wakePollTimer); // 1. Play two-note wake chime playWakeChime(); // 2. Gold ripple flash showWakeFlash(); // 3. Start dialog — hand ambient stream to dialog, avoid iOS getUserMedia block // Stop ambient MediaRecorder (releases recorder lock) but KEEP the stream alive if (typeof ambientMediaRecorder !== 'undefined' && ambientMediaRecorder) { try { ambientMediaRecorder.stop(); } catch(e) {} ambientMediaRecorder = null; } // Give dialog mode the live ambient stream so it skips getUserMedia entirely if (typeof ambientStream !== 'undefined' && ambientStream) { dialogStream = ambientStream; } // dialogSessionStarted = false → "first session" path: reuses existing stream, // sets up AudioContext fresh, no getUserMedia needed (stream already open) dialogSessionStarted = false; dialogMode = true; dialogLastActivity = Date.now(); // start idle timer from session open updateDialogBtn && updateDialogBtn(); showToast('Listening…'); showVoiceBar && showVoiceBar('● Listening…'); setTimeout(function() { startDialogMode(); }, 120); // Restart wake word when dialog ends (dialogMode=false AND mic idle) var watchdog = setInterval(function() { if (!dialogMode && !micActive && ambientOn) { clearInterval(watchdog); wakeHandled = false; setTimeout(function() { if (wakeWordActive && ambientOn && !dialogMode) { wakeHandled = false; var lbl = document.getElementById('ambientBtnLbl'); if (lbl) lbl.textContent = `"Hey ${AGENT_NAME}"`; runWakePoll(); } }, 800); } }, 500); setTimeout(function() { clearInterval(watchdog); }, 300000); } function playWakeChime() { try { var ctx = new (window.AudioContext || window.webkitAudioContext)(); [[440, 0], [660, 0.18]].forEach(function(pair) { var freq = pair[0], delay = pair[1]; var osc = ctx.createOscillator(); var gain = ctx.createGain(); osc.connect(gain); gain.connect(ctx.destination); osc.type = 'sine'; osc.frequency.value = freq; var t = ctx.currentTime + delay; gain.gain.setValueAtTime(0, t); gain.gain.linearRampToValueAtTime(0.28, t + 0.04); gain.gain.exponentialRampToValueAtTime(0.001, t + 0.5); osc.start(t); osc.stop(t + 0.55); }); setTimeout(function() { try { ctx.close(); } catch(e) {} }, 2000); } catch(e) {} } function showWakeFlash() { if (!document.getElementById('wakeFlashStyle')) { var s = document.createElement('style'); s.id = 'wakeFlashStyle'; s.textContent = '@keyframes wakeFlash{0%{opacity:0}20%{opacity:1}100%{opacity:0}}'; document.head.appendChild(s); } var flash = document.createElement('div'); flash.style.cssText = 'position:fixed;inset:0;background:radial-gradient(ellipse at 50% 15%,rgba(212,175,80,0.22) 0%,transparent 65%);pointer-events:none;z-index:9990;animation:wakeFlash 0.9s ease-out forwards;'; document.body.appendChild(flash); setTimeout(function() { if(flash.parentNode) flash.remove(); }, 950); } // ── Process Buffer → Extract Insights ───── function scheduleAmbientProcess() { if(ambientProcessTimer) clearTimeout(ambientProcessTimer); // Process every 90s or when buffer hits 10 utterances if(ambientBuffer.length >= 10) { processAmbientBuffer(); } else { ambientProcessTimer = setTimeout(processAmbientBuffer, 45000); } } async function processAmbientBuffer() { if(!ambientBuffer.length) return; const chunk = ambientBuffer.splice(0, ambientBuffer.length); const transcript = chunk.map(c=>c.text).join(' '); if(transcript.length < 15) return; try { const prompt = `You are Valet, ${VALET_CONFIG.clientName}'s personal AI. You just overheard a conversation. Extract everything worth remembering. Be specific. Exact words matter for commitments and contacts. Transcript: "${transcript}" Return ONLY valid JSON with these categories (omit empty arrays): { "commitments": [], // Things Owen said he would do: "I'll call Bob Monday", "We're going with option A" "promises_to_owen": [], // Things others committed to Owen: "She said she'd send it by Friday" "contacts": [], // Names + context: "Mike Reynolds - realtor in Boise" "phone_numbers": [], // Any number mentioned: "208-555-1234 - contractor" "decisions": [], // Choices made: "Decided to go with the Zoom meeting" "follow_ups": [], // Things to track: "Waiting on response from..." "facts": [], // Useful facts: "The project budget is $240k" "preferences": [], // Likes/dislikes/opinions: "Owen hates the blue layout" "dates_deadlines": [], // Any date or deadline mentioned "people_context": [] // Anything relevant about a person mentioned } Skip filler words, greetings, and small talk. Be specific and capture exact details.`; // Use GPT-4o for accurate extraction const res = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'gpt-4o', messages: [{ role: 'user', content: prompt }], max_tokens: 600, temperature: 0.1, response_format: { type: 'json_object' } }), signal: AbortSignal.timeout(15000) }); const data = await res.json(); const raw = data.choices?.[0]?.message?.content || '{}'; let insights; try { insights = JSON.parse(raw); } catch(e) { return; } const total = Object.values(insights).flat().length; if(total === 0) return; ambientInsightCount += total; // Merge contacts and phone numbers into persistent contact book if(insights.contacts?.length || insights.phone_numbers?.length) { const contacts = Mem.deep.contacts || []; (insights.contacts||[]).forEach(c => { if(!contacts.includes(c)) contacts.push(c); }); (insights.phone_numbers||[]).forEach(p => { if(!contacts.includes(p)) contacts.push(p); }); Mem.saveDeep('contacts', contacts.slice(0, 500)); } // Merge commitments into persistent tracker if(insights.commitments?.length) { const comms = Mem.deep.commitments || []; insights.commitments.forEach(c => comms.unshift({ text: c, ts: Date.now(), done: false })); Mem.saveDeep('commitments', comms.slice(0, 200)); } // Store full log entry const existing = Mem.deep.ambientLog || []; existing.unshift({ ts: Date.now(), insights, preview: transcript.slice(0, 120) }); Mem.saveDeep('ambientLog', existing.slice(0, 300)); // Update status const ambLbl = document.getElementById('ambientBtnLbl'); if(ambLbl) ambLbl.textContent = `${ambientInsightCount} insights`; // Proactive whisper — Valet speaks only when it adds real value checkAndWhisper(transcript, insights); // Visual nudge on Troy tab (silent in background on other tabs) const nudgeItems = [ ...(insights.commitments||[]).slice(0,2), ...(insights.contacts||[]).slice(0,1), ...(insights.follow_ups||[]).slice(0,1) ]; if(nudgeItems.length && currentTab === 'troy') { setTimeout(() => { addBubble(`Caught that. Logged:\n\n${nudgeItems.map(i=>`• ${i}`).join('\n')}`, false); }, 1200); } } catch(e) { console.warn('Ambient process error:', e.message); } } // ── Ambient Context in every AI call ────── function getAmbientContext() { const parts = []; // Open commitments Owen made (highest priority) const comms = (Mem.deep.commitments || []).filter(c => !c.done).slice(0, 5); if(comms.length) parts.push('Owen has these open commitments:\n' + comms.map(c=>`• ${c.text}`).join('\n')); // Contacts/phone numbers captured const contacts = (Mem.deep.contacts || []).slice(0, 10); if(contacts.length) parts.push('Known contacts:\n' + contacts.map(c=>`• ${c}`).join('\n')); // Recent ambient log — decisions, facts, follow-ups const log = Mem.deep.ambientLog || []; if(log.length) { const recent = log.slice(0, 4).flatMap(e => { const i = e.insights; return [ ...(i.decisions||[]).map(d=>`Decision: ${d}`), ...(i.follow_ups||[]).map(f=>`Following up on: ${f}`), ...(i.facts||[]).map(f=>`Fact: ${f}`), ...(i.dates_deadlines||[]).map(d=>`Deadline: ${d}`), ...(i.promises_to_owen||[]).map(p=>`Promised to Owen: ${p}`) ]; }).slice(0, 10); if(recent.length) parts.push('Recent context:\n' + recent.map(i=>`• ${i}`).join('\n')); } return parts.length ? '\n\nAmbient memory:\n' + parts.join('\n\n') : ''; } // ════════════════════════════════════════ // VALET PROACTIVE WHISPER LAYER // Listens → checks → whispers only when it adds real value // ════════════════════════════════════════ async function valetWhisper(text) { if (speaking || dialogListening) return; // never interrupt dialog try { const res = await fetch('/api/tts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ voice_id: EL_VOICE, text: text, model_id: 'eleven_turbo_v2', voice_settings: { stability: 0.40, similarity_boost: 0.75, style: 0.10, use_speaker_boost: true } }) }); if (!res.ok) return; const blob = await res.blob(); const url = URL.createObjectURL(blob); const audio = new Audio(url); audio.volume = 0.42; // whisper — audible in ear, won't fill a room audio.onended = () => URL.revokeObjectURL(url); await audio.play(); // Gold pulse on ambient dot = Valet spoke const dot = document.getElementById('ambientDot'); if (dot) { const prev = dot.style.background; dot.style.background = 'var(--gold)'; dot.style.boxShadow = '0 0 8px var(--gold)'; setTimeout(() => { dot.style.background = '#ef4444'; dot.style.boxShadow = '0 0 6px #ef4444'; }, 2500); } } catch(e) { /* silent — whisper failures must never surface */ } } async function checkAndWhisper(transcript, insights) { if (speaking || dialogListening) return; const ownerName = Mem.deep?.identity?.name?.split(' ')[0] || VALET_CONFIG.clientFirst; const ctx = getAmbientContext(); const knownFacts = [ `Identity: ${JSON.stringify(Mem.deep?.identity||{})}`, `Family: ${JSON.stringify(Mem.deep?.family||{})}`, `Work: ${JSON.stringify(Mem.deep?.work||{})}`, `Investments: ${JSON.stringify(Mem.deep?.investments||{})}`, ctx ? `Recent context: ${ctx}` : '' ].filter(Boolean).join('\n'); const prompt = `You are Valet — ${ownerName}'s personal AI running silently as an ambient earpiece. You just heard this: "${transcript}" What you extracted from it: ${JSON.stringify(insights)} What you know about ${ownerName}: ${knownFacts} Should you whisper something to ${ownerName} RIGHT NOW? Be extremely selective. Only speak if: - You have a specific fact, number, name, or context directly relevant to what was JUST said - The information would genuinely help in this exact moment - It's something they likely don't already know or remember If YES: Write exactly 1-2 short sentences. Lead with the key fact. No preamble like "I noticed" or "Just so you know". If NO: Respond with exactly: SILENT Good whispers: - "Laura's birthday is coming up." - "Their contract expires April 30th. You budgeted $240k." - "Tyler has a game Saturday at 10 AM." Bad whispers (respond SILENT): - Generic observations about the conversation - Things ${ownerName} clearly already knows - Motivational commentary - Anything that can wait`; try { const res = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'gpt-4o', messages: [{ role: 'user', content: prompt }], max_tokens: 80, temperature: 0.2, stream: false }), signal: AbortSignal.timeout(10000) }); const data = await res.json(); const reply = (data.choices?.[0]?.message?.content || 'SILENT').trim(); if (reply && reply !== 'SILENT' && reply.length > 8) { await valetWhisper(reply); } } catch(e) { /* silent fail — never surface ambient errors */ } } // ════════════════════════════════════════ // VALET SCHEDULE ENGINE // ════════════════════════════════════════ const VALET_DEFAULTS = { morningBrief: { enabled: true, time: '07:00' }, preMeeting: { enabled: true, leadMinutes: 15 }, emailWatch: { enabled: true, intervalMinutes: 20 }, marketWatch: { enabled: true, thresholdPct: 3 }, commitmentCheck: { enabled: true }, eveningWrap: { enabled: true, time: '17:30' }, contactCRM: { enabled: true }, travelPrep: { enabled: true } }; function loadValetSchedule() { try { return JSON.parse(localStorage.getItem('valet_schedule') || JSON.stringify(VALET_DEFAULTS)); } catch(e) { return Object.assign({}, VALET_DEFAULTS); } } function saveValetSchedule(s) { localStorage.setItem('valet_schedule', JSON.stringify(s)); } // ─── module-level interval handles (must be clearable) ─── let calWatchInterval = null; let emailWatchInterval = null; let marketWatchInterval = null; let calWatchedEvents = new Set(); let emailWatchLastSeen = new Set(); let marketWatchBaseline = {}; function bootValetEngine() { // idempotent — clear all existing intervals before re-arming if (calWatchInterval) { clearInterval(calWatchInterval); calWatchInterval = null; } if (emailWatchInterval) { clearInterval(emailWatchInterval); emailWatchInterval = null; } if (marketWatchInterval) { clearInterval(marketWatchInterval); marketWatchInterval = null; } const s = loadValetSchedule(); if (s.morningBrief.enabled) scheduleMorningBrief(s.morningBrief.time); if (s.eveningWrap.enabled) scheduleEveningWrap(s.eveningWrap.time); if (s.preMeeting.enabled) startCalendarWatch(s.preMeeting.leadMinutes); if (s.emailWatch.enabled) startEmailWatch(s.emailWatch.intervalMinutes); if (s.marketWatch.enabled) startMarketWatch(s.marketWatch.thresholdPct); if (s.commitmentCheck.enabled) scheduleCommitmentCheck(); if (s.contactCRM.enabled) scheduleContactCRM(); if (s.travelPrep.enabled) checkTravelPrep(); } // ─── UTILITY: schedule a function to fire at a specific wall-clock time daily ─── function scheduleAtTime(timeStr, fn) { const [h, m] = timeStr.split(':').map(Number); function getNextMs() { const now = new Date(); const target = new Date(); target.setHours(h, m, 0, 0); if (target <= now) target.setDate(target.getDate() + 1); return target - now; } function run() { fn(); setTimeout(run, 24 * 60 * 60 * 1000); } setTimeout(run, getNextMs()); } // ─── MORNING BRIEF ─── function scheduleMorningBrief(timeStr) { scheduleAtTime(timeStr, deliverMorningBrief); } async function deliverMorningBrief() { const today = new Date().toDateString(); if (localStorage.getItem('valet_brief_date') === today) return; localStorage.setItem('valet_brief_date', today); const [calEvents, gmailSnippet, weatherSnippet, marketSnippet] = await Promise.allSettled([ fetchTodayCalEvents(), fetchImportantEmails(3), fetchWeatherBrief(), fetchMarketBrief() ]); const ownerName = Mem.deep?.identity?.name?.split(' ')[0] || Mem.deep?.profile?.nick || VALET_CONFIG.clientFirst; const prompt = `Write a natural, spoken morning brief for ${ownerName}. Sound like a trusted valet — warm, efficient, no fluff. 3-4 sentences max. Cover what matters today. End with one open question or focus for the day. Calendar today: ${calEvents.value || 'No events found'} Important emails: ${gmailSnippet.value || 'Inbox clear'} Weather: ${weatherSnippet.value || 'Weather unavailable'} Markets: ${marketSnippet.value || 'Markets unavailable'} Open commitments: ${getOpenCommitments()} Speak directly. No "Good morning" opener — Valet doesn't do that. Start with the most important thing.`; try { const res = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'gpt-4o', messages: [{ role: 'user', content: prompt }], max_tokens: 120, stream: false }) }); const data = await res.json(); const brief = data.choices?.[0]?.message?.content?.trim(); if (!brief) return; addBubble(`🌅 Morning brief
${brief}`, false); speakReply(brief); } catch(e) {} } async function fetchTodayCalEvents() { if (!gToken) return null; try { const now = new Date(); now.setHours(0,0,0,0); const end = new Date(now); end.setHours(23,59,59,999); const r = await fetch( `https://www.googleapis.com/calendar/v3/calendars/primary/events?timeMin=${encodeURIComponent(now.toISOString())}&timeMax=${encodeURIComponent(end.toISOString())}&singleEvents=true&orderBy=startTime&maxResults=10`, { headers: { 'Authorization': `Bearer ${gToken}` } } ); const d = await r.json(); const evts = (d.items || []).filter(e => e.start?.dateTime || e.start?.date); if (!evts.length) return 'No events today'; return evts.map(e => { const t = e.start?.dateTime ? new Date(e.start.dateTime).toLocaleTimeString([], {hour:'numeric',minute:'2-digit'}) : 'All day'; return `${t} ${e.summary || 'Event'}`; }).join(', '); } catch(e) { return null; } } async function fetchImportantEmails(limit = 3) { if (!gToken) return null; try { const r = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/messages?q=is:unread in:inbox&maxResults=10', { headers: { 'Authorization': `Bearer ${gToken}` } }); const d = await r.json(); const msgs = (d.messages || []).slice(0, limit + 4); if (!msgs.length) return 'Inbox clear'; const details = await Promise.all(msgs.map(async m => { const mr = await fetch( `https://gmail.googleapis.com/gmail/v1/users/me/messages/${m.id}?format=metadata&metadataHeaders=From&metadataHeaders=Subject`, { headers: { 'Authorization': `Bearer ${gToken}` } } ); return mr.json(); })); const real = details.filter(m => { const from = m.payload?.headers?.find(h => h.name === 'From')?.value || ''; return !/(noreply|no-reply|donotreply|newsletter|notifications?@|alerts?@|support@|hello@|info@|mailer|mailchimp|sendgrid)/i.test(from); }).slice(0, limit); if (!real.length) return 'Inbox clear'; return real.map(m => { const from = m.payload?.headers?.find(h => h.name === 'From')?.value || 'Unknown'; const subj = m.payload?.headers?.find(h => h.name === 'Subject')?.value || '(no subject)'; const name = from.replace(/<.*>/, '').trim().split(' ')[0]; return `Email from ${name}: ${subj}`; }).join('; '); } catch(e) { return null; } } async function fetchWeatherBrief() { try { const url = 'https://api.open-meteo.com/v1/forecast?latitude=43.6919&longitude=-116.3573¤t=temperature_2m,weathercode&daily=temperature_2m_max&temperature_unit=fahrenheit&timezone=America%2FBoise&forecast_days=1'; const res = await fetch(url, { signal: AbortSignal.timeout(6000) }); const d = await res.json(); const cur = Math.round(d.current?.temperature_2m); const high = Math.round(d.daily?.temperature_2m_max?.[0]); const code = d.current?.weathercode; const desc = WX_CODE?.[code] || 'Clear'; return `${desc}, ${cur}°F. High of ${high}°.`; } catch(e) { return null; } } async function fetchMarketBrief() { const stocks = ['NVDA','TSM','CEG']; const parts = []; for (const sym of stocks) { try { const r = await fetch(`https://query1.finance.yahoo.com/v8/finance/chart/${sym}?interval=1d&range=1d`); const d = await r.json(); const meta = d?.chart?.result?.[0]?.meta; const price = meta?.regularMarketPrice; const prev = meta?.chartPreviousClose; if (!price || !prev) continue; const chg = ((price - prev) / prev) * 100; const sign = chg >= 0 ? '+' : ''; parts.push(`${sym} ${sign}${chg.toFixed(1)}%`); } catch(e) {} } // XRP + BTC via CoinGecko try { const r = await fetch('https://api.coingecko.com/api/v3/simple/price?ids=ripple,bitcoin&vs_currencies=usd&include_24hr_change=true'); const d = await r.json(); if (d.ripple) parts.push(`XRP $${d.ripple.usd.toFixed(2)} (${d.ripple.usd_24h_change?.toFixed(1)}%)`); if (d.bitcoin) parts.push(`BTC $${Math.round(d.bitcoin.usd).toLocaleString()}`); } catch(e) {} return parts.length ? parts.join(', ') : null; } function getOpenCommitments() { const comms = (Mem.deep?.commitments || []).filter(c => !c.done).slice(0, 3); return comms.length ? comms.map(c => c.text).join('; ') : 'None tracked'; } // ─── CALENDAR WATCH ─── function startCalendarWatch(leadMinutes = 15) { if (calWatchInterval) clearInterval(calWatchInterval); checkUpcomingMeetings(leadMinutes); calWatchInterval = setInterval(() => checkUpcomingMeetings(leadMinutes), 5 * 60 * 1000); } async function checkUpcomingMeetings(leadMinutes) { if (!gToken) return; const now = new Date(); const windowEnd = new Date(now.getTime() + (leadMinutes + 2) * 60000); try { const r = await fetch( `https://www.googleapis.com/calendar/v3/calendars/primary/events?timeMin=${encodeURIComponent(now.toISOString())}&timeMax=${encodeURIComponent(windowEnd.toISOString())}&singleEvents=true&orderBy=startTime&maxResults=5`, { headers: { 'Authorization': `Bearer ${gToken}` } } ); const d = await r.json(); for (const ev of (d.items || [])) { if (!ev.start?.dateTime) continue; if (calWatchedEvents.has(ev.id)) continue; const startTime = new Date(ev.start.dateTime); const minsUntil = Math.round((startTime - now) / 60000); if (minsUntil >= 0 && minsUntil <= leadMinutes) { calWatchedEvents.add(ev.id); const msg = minsUntil <= 2 ? `${ev.summary || 'Meeting'} is starting now.` : `${ev.summary || 'Meeting'} in ${minsUntil} minutes.`; valetWhisper(msg); addBubble(`📅 ${ev.summary || 'Meeting'} in ${minsUntil} min`, false); } } } catch(e) {} } // ─── EMAIL INTELLIGENCE WATCH ─── function startEmailWatch(intervalMinutes = 20) { if (emailWatchInterval) clearInterval(emailWatchInterval); emailWatchInterval = setInterval(() => checkImportantEmails(), intervalMinutes * 60 * 1000); } async function checkImportantEmails() { if (!gToken) return; try { const r = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/messages?q=is:unread in:inbox&maxResults=10', { headers: { 'Authorization': `Bearer ${gToken}` } }); const d = await r.json(); const msgs = d.messages || []; const newMsgs = msgs.filter(m => !emailWatchLastSeen.has(m.id)); if (!newMsgs.length) return; const details = await Promise.all(newMsgs.slice(0, 5).map(async m => { emailWatchLastSeen.add(m.id); const mr = await fetch( `https://gmail.googleapis.com/gmail/v1/users/me/messages/${m.id}?format=metadata&metadataHeaders=From&metadataHeaders=Subject`, { headers: { 'Authorization': `Bearer ${gToken}` } } ); return mr.json(); })); const realEmails = details.filter(m => { const from = m.payload?.headers?.find(h => h.name === 'From')?.value || ''; return !/(noreply|no-reply|donotreply|newsletter|notifications?@|alerts?@|support@|hello@|info@|mailer|mailchimp|sendgrid)/i.test(from); }); if (!realEmails.length) return; const summary = realEmails.map(m => { const from = m.payload?.headers?.find(h => h.name === 'From')?.value || 'Unknown'; const subj = m.payload?.headers?.find(h => h.name === 'Subject')?.value || '(no subject)'; const name = from.replace(/<.*>/, '').trim().split(' ')[0]; return `${name}: ${subj}`; }).join(' · '); const whisperText = realEmails.length === 1 ? `Email from ${summary}` : `${realEmails.length} new emails — ${summary}`; valetWhisper(whisperText); addBubble(`📧 ${realEmails.length} new ${realEmails.length === 1 ? 'email' : 'emails'}
${summary}
View inbox
Compose
`, false); } catch(e) {} } // ─── MARKET WATCH ─── function startMarketWatch(thresholdPct = 3) { if (marketWatchInterval) clearInterval(marketWatchInterval); setMarketBaseline().then(() => { marketWatchInterval = setInterval(() => checkMarketMoves(thresholdPct), 15 * 60 * 1000); }); } async function setMarketBaseline() { for (const sym of ['NVDA','TSM','CEG']) { try { const r = await fetch(`https://query1.finance.yahoo.com/v8/finance/chart/${sym}?interval=1d&range=1d`); const d = await r.json(); const price = d?.chart?.result?.[0]?.meta?.regularMarketPrice; if (price) marketWatchBaseline[sym] = price; } catch(e) {} } } async function checkMarketMoves(thresholdPct) { const alerts = []; for (const sym of ['NVDA','TSM','CEG']) { try { const r = await fetch(`https://query1.finance.yahoo.com/v8/finance/chart/${sym}?interval=1d&range=1d`); const d = await r.json(); const meta = d?.chart?.result?.[0]?.meta; const current = meta?.regularMarketPrice; const prev = meta?.chartPreviousClose; if (!current || !prev) continue; const changePct = ((current - prev) / prev) * 100; if (Math.abs(changePct) >= thresholdPct) { const dir = changePct > 0 ? 'up' : 'down'; alerts.push(`${sym} is ${dir} ${Math.abs(changePct).toFixed(1)}% at $${current.toFixed(2)}`); } } catch(e) {} } if (alerts.length) { valetWhisper(alerts.join('. ')); addBubble(`📈 Market alert
${alerts.map(a=>`• ${a}`).join('
')}`, false); } } // ─── COMMITMENT CHECK-IN ─── function scheduleCommitmentCheck() { scheduleAtTime('10:00', deliverCommitmentCheck); scheduleAtTime('14:00', deliverCommitmentCheck); } async function deliverCommitmentCheck() { const open = (Mem.deep?.commitments || []).filter(c => !c.done).slice(0, 5); if (!open.length) return; const names = open.map(c => c.text); const msg = open.length === 1 ? `Still open: ${names[0]}` : `${open.length} open commitments. Top one: ${names[0]}`; valetWhisper(msg); addBubble(`✅ Open commitments
${names.map(n=>`• ${n}`).join('
')}
${open.slice(0,3).map((_,i)=>`
Done ✓
`).join('')}
`, false); } function markCommitmentDone(idx) { const comms = Mem.deep?.commitments || []; const open = comms.filter(c => !c.done); if (open[idx]) { open[idx].done = true; Mem.saveDeep('commitments', comms); showToast('Marked done ✓'); } } // ─── EVENING WRAP ─── function scheduleEveningWrap(timeStr) { scheduleAtTime(timeStr, deliverEveningWrap); } async function deliverEveningWrap() { const today = new Date().toDateString(); if (localStorage.getItem('valet_wrap_date') === today) return; localStorage.setItem('valet_wrap_date', today); const openComms = (Mem.deep?.commitments || []).filter(c => !c.done).slice(0, 5); const recentInsights = (Mem.deep?.ambientLog || []).slice(0, 3).map(e => e.preview).join('; '); const prompt = `Write a concise evening wrap-up for Owen. 3 sentences max. What's still open, what's on tomorrow, and one thing to leave work with. Tone: calm, efficient, like a trusted assistant closing out the day. No filler. Open commitments: ${openComms.map(c=>c.text).join('; ') || 'None'} Key things heard today: ${recentInsights || 'Nothing captured'}`; try { const res = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'gpt-4o', messages: [{ role: 'user', content: prompt }], max_tokens: 100, stream: false }) }); const data = await res.json(); const wrap = data.choices?.[0]?.message?.content?.trim(); if (!wrap) return; addBubble(`🌙 Evening wrap
${wrap}`, false); speakReply(wrap); } catch(e) {} } // ─── CONTACT CRM ─── function scheduleContactCRM() { scheduleAtTime('09:30', checkContactCRM); } async function checkContactCRM() { const contacts = Mem.deep?.crmContacts || [ { name: 'Laura', normalDays: 3 }, { name: 'Chase', normalDays: 7 }, { name: 'Erik', normalDays: 7 }, ]; const overdue = contacts.filter(c => { if (!c.lastContact) return false; const days = Math.floor((Date.now() - c.lastContact) / 86400000); return days > (c.normalDays * 1.5); }); if (!overdue.length) return; const names = overdue.map(c => c.name).join(', '); valetWhisper(`Haven't heard from ${names} in a while.`); addBubble(`👥 Touch base
${overdue.map(c=>`• ${c.name}`).join('
')}`, false); } // ─── TRAVEL PREP ─── async function checkTravelPrep() { if (!gToken) return; const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1); tomorrow.setHours(0,0,0,0); const tomorrowEnd = new Date(tomorrow); tomorrowEnd.setHours(23,59,59,999); try { const r = await fetch( `https://www.googleapis.com/calendar/v3/calendars/primary/events?timeMin=${encodeURIComponent(tomorrow.toISOString())}&timeMax=${encodeURIComponent(tomorrowEnd.toISOString())}&singleEvents=true&orderBy=startTime&maxResults=10`, { headers: { 'Authorization': `Bearer ${gToken}` } } ); const d = await r.json(); const travelEv = (d.items || []).find(e => /(flight|airport|travel|trip|fly|hotel|conference|denver|boise|seattle|portland|phoenix|las vegas|salt lake)/i.test(e.summary || '') ); if (travelEv) { addBubble(`✈️ Travel tomorrow — ${travelEv.summary}
Want me to prep weather and logistics?`, false); } } catch(e) {} } // ─── SETTINGS: Valet toggle helpers ─── function toggleValetSetting(key, el) { el.classList.toggle('on'); const s = loadValetSchedule(); s[key].enabled = el.classList.contains('on'); saveValetSchedule(s); bootValetEngine(); } function saveValetSetting(key, field, value) { const s = loadValetSchedule(); s[key][field] = value; saveValetSchedule(s); bootValetEngine(); } function initValetSettingsUI() { const s = loadValetSchedule(); const map = { 'vs-morning-tog': 'morningBrief', 'vs-meeting-tog': 'preMeeting', 'vs-email-tog': 'emailWatch', 'vs-market-tog': 'marketWatch', 'vs-commit-tog': 'commitmentCheck', 'vs-evening-tog': 'eveningWrap', 'vs-crm-tog': 'contactCRM', 'vs-travel-tog': 'travelPrep' }; Object.entries(map).forEach(([id, key]) => { const el = document.getElementById(id); if (el && s[key]?.enabled) el.classList.add('on'); else if (el) el.classList.remove('on'); }); const mt = document.getElementById('vs-morning-time'); if (mt) mt.value = s.morningBrief.time || '07:00'; const et = document.getElementById('vs-evening-time'); if (et) et.value = s.eveningWrap.time || '17:30'; } // ════════════════════════════════════════ // LENS — CAMERA + GEMINI VISION // ════════════════════════════════════════ let lensStream = null; let lensMode = 'auto'; let lensCapturedData = null; const LENS_PROMPTS = { auto: "What am I looking at? Be specific and genuinely helpful. If it's a product, tell me the brand, model, and what it does. If it's a place or building, identify it. If it's food, tell me what it is and whether it's worth eating. If it's a document or sign, summarize it. If it's something unusual or interesting, explain why. Be direct, specific, and under 120 words.", receipt: "This is a receipt. Extract and format everything: vendor/restaurant name, date, every line item with price, subtotal, tax, tip if any, and total. Then in one line suggest an expense category (meals, office supplies, travel, etc.). Format cleanly with labels.", food: "What food is this? Be specific — dish name, cuisine, key ingredients if visible. Is it something ${VALET_CONFIG.clientName} (busy CFO, Boise Idaho, Ironman athlete) would actually enjoy? Rate it 1-10 honestly. Any notable nutritional or quality observations?", place: "Based on everything visible in this photo — architecture, signage, landscape, style, vegetation, infrastructure — where might this be? Be specific about region, type of place, and any identifying features you notice. What kind of place is this?", text: "Read and transcribe all text visible in this image. Format it cleanly, preserving structure where possible (headers, lists, tables). If it's a form, business card, or sign — note what type it is." }; async function startLens() { try { lensStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } } }); const video = document.getElementById('lensVideo'); video.srcObject = lensStream; document.getElementById('lensPrompt').style.display = 'none'; document.getElementById('lensVideo').style.display = 'block'; } catch(e) { // Fallback to front camera try { lensStream = await navigator.mediaDevices.getUserMedia({ video: true }); const video = document.getElementById('lensVideo'); video.srcObject = lensStream; document.getElementById('lensPrompt').style.display = 'none'; } catch(e2) { alert('Camera access denied. Please allow camera in your browser settings.'); } } } function setLensMode(mode) { lensMode = mode; document.querySelectorAll('.lens-mode').forEach(el => el.classList.remove('active')); const el = document.getElementById('lm-' + mode); if(el) el.classList.add('active'); } async function captureAndAnalyze() { if(!lensStream && !lensCapturedData) { await startLens(); if(!lensStream) return; setTimeout(captureAndAnalyze, 800); return; } // Capture frame from video const video = document.getElementById('lensVideo'); const canvas = document.getElementById('lensCanvas'); canvas.width = video.videoWidth || 640; canvas.height = video.videoHeight || 480; const ctx = canvas.getContext('2d'); ctx.drawImage(video, 0, 0, canvas.width, canvas.height); // Show captured image, hide video const dataUrl = canvas.toDataURL('image/jpeg', 0.85); lensCapturedData = dataUrl.split(',')[1]; // base64 only const captured = document.getElementById('lensCaptured'); captured.src = dataUrl; captured.style.display = 'block'; video.style.display = 'none'; // Show retake + save buttons document.getElementById('lensRetakeBtn').style.display = 'inline-flex'; document.getElementById('lensSaveBtn').style.display = 'inline-flex'; // Show analyzing state const resultInner = document.getElementById('lensResultInner'); const analyzing = document.getElementById('lensAnalyzing'); const answer = document.getElementById('lensAnswer'); resultInner.style.display = 'block'; analyzing.style.display = 'block'; answer.style.display = 'none'; answer.innerHTML = ''; document.getElementById('lensActions').innerHTML = ''; // Call GPT-4o Vision try { const prompt = LENS_PROMPTS[lensMode] || LENS_PROMPTS.auto; const res = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'gpt-4o', messages: [{ role: 'user', content: [ { type: 'text', text: buildCtx() + '\n\n' + prompt }, { type: 'image_url', image_url: { url: `data:image/jpeg;base64,${lensCapturedData}`, detail: 'auto' } } ] }], max_tokens: 450, temperature: 0.4 }), signal: AbortSignal.timeout(25000) }); const data = await res.json(); const text = data.choices?.[0]?.message?.content || 'Could not analyze the image. Try again.'; analyzing.style.display = 'none'; answer.style.display = 'block'; answer.innerHTML = text.replace(/\n/g, '
'); // Contextual action buttons const actions = []; if(lensMode === 'receipt') { actions.push({l:'Log as expense', fn:`chat('Log this expense: ' + document.getElementById('lensAnswer').innerText.slice(0,200))`}); actions.push({l:'Send to email', fn:`chat('Draft an email to myself summarizing this receipt for expense reporting')`}); } else if(lensMode === 'food') { actions.push({l:'Order this', fn:`chat('I want to order something similar to what I just photographed — find me options near Eagle Idaho')`}); } else if(lensMode === 'place') { actions.push({l:'Navigate here', fn:`window.open('maps:///?q=Current+Location')`}); } actions.push({l:'Ask Troy about this', fn:`chat('I just photographed something — ' + document.getElementById('lensAnswer').innerText.slice(0,150) + ' — what should I know about this?');goTab('troy')`}); document.getElementById('lensActions').innerHTML = actions.map(a => `
${a.l}
` ).join(''); } catch(err) { analyzing.style.display = 'none'; answer.style.display = 'block'; answer.innerHTML = 'Could not connect to analysis engine. Check your internet connection and try again.'; } } function retakeLens() { lensCapturedData = null; const video = document.getElementById('lensVideo'); const captured = document.getElementById('lensCaptured'); captured.style.display = 'none'; video.style.display = 'block'; document.getElementById('lensRetakeBtn').style.display = 'none'; document.getElementById('lensSaveBtn').style.display = 'none'; document.getElementById('lensResultInner').style.display = 'none'; if(!lensStream) startLens(); } function saveLensPhoto() { if(!lensCapturedData) return; const a = document.createElement('a'); a.href = 'data:image/jpeg;base64,' + lensCapturedData; a.download = 'troy-lens-' + new Date().toISOString().slice(0,19).replace(/[T:]/g,'-') + '.jpg'; a.click(); } // ════════════════════════════════════════ // SETTINGS ENGINE // ════════════════════════════════════════ const TROY_VOICES = [ { id: 'bIHbv24MWmeRgasZH58o', label: 'Will', desc: 'Young · Relaxed · Chill American' }, { id: 'TX3LPaxmHKxFdv7VOQHJ', label: 'Liam', desc: 'Young · Energetic · Confident' }, { id: 'vBKc2FfBKJfcZNyEt1n6', label: 'Finn', desc: 'Young · Upbeat · Conversational' }, { id: 'CwhRBWXzGAHq8TQ4Fs17', label: 'Roger', desc: 'Laid-Back · Casual · Resonant' }, { id: 'iP95p4xoKVk53GoZ742B', label: 'Chris', desc: 'Charming · Down-to-Earth · Casual' }, ]; const PREFS = JSON.parse(localStorage.getItem('troy-prefs') || '{"tone":"friend","humor":"dry","length":"short","nudge":"on","theme":"auto","accent":"#B8962E","voice":"bIHbv24MWmeRgasZH58o","speed":1.0,"fontSize":15}'); // Force reset if cached voice was one of the bad generated ones const BAD_VOICES = ['wlfqEINcd6v1eQddb3fC','UlDsT58rtcL0AIaBa814','QZg7lsSRBACjGUYqHBzD','FeJ4FSiMLPwj3k2NKFCB','QcnsEpZZQ9Hz8VbHoIbH','1ab3X8BrTVeS5zyIGKGJ']; if(BAD_VOICES.includes(PREFS.voice)) { PREFS.voice = 'bIHbv24MWmeRgasZH58o'; localStorage.setItem('troy-prefs', JSON.stringify(PREFS)); } function savePrefs() { localStorage.setItem('troy-prefs', JSON.stringify(PREFS)); } function renderVoiceList() { const el = document.getElementById('voiceList'); if(!el) return; el.innerHTML = TROY_VOICES.map(v => `
${v.label}
${v.desc}
${PREFS.voice===v.id?'':''}
`).join(''); } function selectVoice(id) { PREFS.voice = id; savePrefs(); renderVoiceList(); } async function previewVoice(id) { const previewText = "Yeah so Tuesday looks good — low wind, clear skies. You've got that board call Wednesday afternoon though, so if you want a full day on the water, Tuesday's your move."; try { const res = await fetch('/api/tts', { method:'POST', headers:{'Content-Type':'application/json','Accept':'audio/mpeg'}, body: JSON.stringify({ voice_id: id, text: previewText, model_id:'eleven_turbo_v2', voice_settings:{stability:0.35,similarity_boost:0.75,style:0.20,use_speaker_boost:true} }), signal: AbortSignal.timeout(12000) }); if(!res.ok) throw new Error('preview failed'); const blob = await res.blob(); const url = URL.createObjectURL(blob); const audio = new Audio(url); audio.onended = () => URL.revokeObjectURL(url); audio.play(); } catch(e) { addBubble('Preview failed — try again', false); } } function setPref(el) { const pref = el.dataset.pref; const val = el.dataset.val; PREFS[pref] = val; savePrefs(); document.querySelectorAll(`.pref-chip[data-pref="${pref}"]`).forEach(c => c.classList.toggle('active', c.dataset.val === val)); } function applyTheme(val) { if(val === 'dark') document.documentElement.setAttribute('data-theme','dark'); else if(val === 'light') document.documentElement.removeAttribute('data-theme'); else document.documentElement.removeAttribute('data-theme'); // auto via media query PREFS.theme = val; savePrefs(); } function setAccent(el) { const color = el.dataset.color; document.querySelectorAll('.color-swatch').forEach(s => s.classList.toggle('active', s===el)); document.documentElement.style.setProperty('--gold', color); PREFS.accent = color; savePrefs(); } function setFontSize(val) { document.documentElement.style.fontSize = val + 'px'; document.getElementById('fontSizeVal').textContent = val + 'px'; PREFS.fontSize = parseInt(val); savePrefs(); } function toggleTab(id, show) { const btn = document.getElementById('tb-'+id); if(btn) btn.style.display = show ? '' : 'none'; PREFS['tab_'+id] = show; savePrefs(); } function initSettings() { renderVoiceList(); // Apply saved prefs if(PREFS.accent) document.documentElement.style.setProperty('--gold', PREFS.accent); if(PREFS.theme) applyTheme(PREFS.theme); if(PREFS.fontSize) { document.documentElement.style.fontSize = PREFS.fontSize+'px'; const el=document.getElementById('fontSizeSlider');if(el)el.value=PREFS.fontSize; const el2=document.getElementById('fontSizeVal');if(el2)el2.textContent=PREFS.fontSize+'px'; } if(PREFS.speed) { const el=document.getElementById('voiceSpeed');if(el)el.value=PREFS.speed; const el2=document.getElementById('voiceSpeedVal');if(el2)el2.textContent=parseFloat(PREFS.speed).toFixed(2)+'×'; } // Mark active prefs ['tone','humor','length','nudge','theme'].forEach(pref => { const val = PREFS[pref]; if(val) document.querySelectorAll(`.pref-chip[data-pref="${pref}"]`).forEach(c => c.classList.toggle('active', c.dataset.val===val)); }); // Restore hidden tabs ['home','cal','life','world','lens'].forEach(id => { if(PREFS['tab_'+id]===false) { const btn=document.getElementById('tb-'+id);if(btn)btn.style.display='none'; const cb=document.getElementById('tab'+id.charAt(0).toUpperCase()+id.slice(1));if(cb)cb.checked=false; } }); } // Lens cleanup is now inlined into the original goTab — no wrapper needed // ════════════════════════════════════════ // ADMIN PAGE // ════════════════════════════════════════ const ADM_VOICES = [ { id:'IKne3meq5aSn9XLyUdCD', name:'Charlie', desc:'Natural · Conversational · Relaxed' }, { id:'bIHbv24MWmeRgasZH58o', name:'Will', desc:'Warm · Optimistic · Young American' }, { id:'TX3LPaxmHKxFdv7VOQHJ', name:'Liam', desc:'Casual · Friendly · Clear' } ]; function admToggle(hdr) { const body = hdr.nextElementSibling; const arr = hdr.querySelector('.adm-arr'); const open = body.style.display !== 'none'; body.style.display = open ? 'none' : ''; if (arr) arr.style.transform = open ? 'rotate(-90deg)' : ''; } function admSave(key, val) { if (!Mem.deep.profile) Mem.deep.profile = {}; Mem.deep.profile[key] = val; Mem.save(); showToast('Saved ✓'); } function admSilence(val) { DIALOG_SILENCE_MS = parseFloat(val) * 1000; document.getElementById('adm-sil-val').textContent = val + 's'; admSave('silenceMs', DIALOG_SILENCE_MS); } function admResponseLen(val) { const v = parseInt(val); const lbl = v<=100?'Brief':v<=200?'Balanced':v<=300?'Detailed':'Full'; document.getElementById('adm-len-val').textContent = lbl; admSave('maxTokens', v); } function admTTSModel(model) { document.getElementById('adm-flash')?.classList.toggle('active', model==='eleven_turbo_v2'); document.getElementById('adm-turbo')?.classList.toggle('active', model==='eleven_turbo_v2'); admSave('ttsModel', model); } function admModel(model) { ['adm-m-mini','adm-m-4o','adm-m-gemini'].forEach(id=>document.getElementById(id)?.classList.remove('active')); const map={'gpt-4o-mini':'adm-m-mini','gpt-4o':'adm-m-4o','gemini':'adm-m-gemini'}; if(map[model]) document.getElementById(map[model])?.classList.add('active'); admSave('aiModel', model); } function admToggleFeature(key, el) { el.classList.toggle('on'); admSave('feat_'+key, el.classList.contains('on')); } function admProactive(el) { el.classList.toggle('on'); const topic=el.dataset.topic; if(!Mem.deep.profile) Mem.deep.profile={}; if(!Mem.deep.profile.proactiveTopics) Mem.deep.profile.proactiveTopics=[]; const arr=Mem.deep.profile.proactiveTopics, idx=arr.indexOf(topic); if(el.classList.contains('on')&&idx===-1) arr.push(topic); else if(!el.classList.contains('on')&&idx>-1) arr.splice(idx,1); Mem.save(); showToast('Saved ✓'); } function admAddFamily() { if(!Mem.deep.profile) Mem.deep.profile={}; if(!Mem.deep.profile.family) Mem.deep.profile.family=[]; Mem.deep.profile.family.push({name:'',relation:''}); Mem.save(); admRenderFamily(); } function admRenderFamily() { const list=document.getElementById('adm-family-list'); if(!list) return; const fam=Mem.deep?.profile?.family||[]; list.innerHTML=fam.map((p,i)=>`
`).join(''); } function admFamilyUpdate(i,key,val){if(Mem.deep?.profile?.family?.[i]){Mem.deep.profile.family[i][key]=val;Mem.save();}} function admRemoveFamily(i){Mem.deep.profile.family.splice(i,1);Mem.save();admRenderFamily();} function admAddTicker() { const inp=document.getElementById('adm-ticker-inp'), val=(inp?.value||'').trim().toUpperCase(); if(!val) return; if(!Mem.deep.profile) Mem.deep.profile={}; if(!Mem.deep.profile.tickers) Mem.deep.profile.tickers=[]; val.split(/[,\s]+/).forEach(t=>{if(t&&!Mem.deep.profile.tickers.includes(t))Mem.deep.profile.tickers.push(t);}); if(inp) inp.value=''; Mem.save(); admRenderTickers(); showToast('Added'); } function admRenderTickers() { const el=document.getElementById('adm-tickers'); if(!el) return; const tickers=Mem.deep?.profile?.tickers||[]; el.innerHTML=tickers.map((t,i)=>`
${t}
`).join(''); } function admRemoveTicker(i){Mem.deep.profile.tickers.splice(i,1);Mem.save();admRenderTickers();} function admClearSession(){if(!confirm('Clear session memory?'))return;if(Mem.working)Mem.working={};if(Mem.session)Mem.session={};Mem.save();showToast('Session cleared');} function admExportMemory(){const data=JSON.stringify({deep:Mem.deep,working:Mem.working,session:Mem.session},null,2);const blob=new Blob([data],{type:'application/json'});const url=URL.createObjectURL(blob);const a=document.createElement('a');a.href=url;a.download='troy-memory-export.json';a.click();URL.revokeObjectURL(url);} function admResetOnboarding(){if(!confirm('Re-run the setup wizard?'))return;if(Mem.deep.onboarding)delete Mem.deep.onboarding;Mem.save();obStart();} function admSelectVoice(id,row){ document.querySelectorAll('.adm-voice-row').forEach(r=>r.classList.remove('selected')); row.classList.add('selected'); if(PREFS) PREFS.voice=id; admSave('voice',id); } function admPreviewVoice(id){ const old=PREFS?.voice; if(PREFS) PREFS.voice=id; speakReply("Yeah so Tuesday looks good — low wind, clear skies. Your board call is Wednesday afternoon, so Tuesday's your move if you want the full day."); setTimeout(()=>{if(PREFS&&old)PREFS.voice=old;},500); } function admLoad() { if (!Mem || !Mem.deep) return; const p=Mem.deep?.profile||{}; const sv=(id,val)=>{const el=document.getElementById(id);if(el&&val!==undefined)el.value=val;}; sv('adm-name', p.name); sv('adm-nick', p.nick||p.preferredName); sv('adm-city', p.city||p.homeCity); sv('adm-linkedin', p.linkedin||p.social?.linkedin); sv('adm-twitter', p.twitter||p.social?.twitter); sv('adm-instagram', p.instagram||p.social?.instagram); sv('adm-yahoo-user', p.yahooUser||p.email?.yahooUser); sv('adm-avoid', p.avoidTopics||p.prefs?.avoidTopics); const silMs=p.silenceMs||4000; DIALOG_SILENCE_MS=silMs; const silEl=document.getElementById('adm-sil'); if(silEl){silEl.value=silMs/1000;const sv2=document.getElementById('adm-sil-val');if(sv2)sv2.textContent=(silMs/1000)+'s';} if(p.tz){const tzEl=document.getElementById('adm-tz');if(tzEl)tzEl.value=p.tz;} admTTSModel(p.ttsModel||'eleven_turbo_v2'); admModel(p.aiModel||'gpt-4o-mini'); const vList=document.getElementById('adm-voices'); if(vList){const cur=PREFS?.voice||p.voice||EL_VOICE; vList.innerHTML=ADM_VOICES.map(v=>`
${v.name}
${v.desc}
`).join('');} admRenderFamily(); admRenderTickers(); const memEl=document.getElementById('adm-mem-list'); if(memEl){const facts=Mem.deep?.facts||Mem.working?.facts||[];memEl.textContent=facts.length?facts.slice(-20).join('\n'):'Nothing captured yet.';} const icEl=document.getElementById('adm-insight-ct');if(icEl)icEl.textContent=ambientInsightCount||0; const gmailEl=document.getElementById('adm-gmail-status'); if(gmailEl){const em=Mem.deep?.googleEmail||'';gmailEl.textContent=em?'✓ '+em:'Not connected';gmailEl.style.color=em?'var(--gold)':'var(--t4)';} initValetSettingsUI(); } // ════════════════════════════════════════ // ONBOARDING WIZARD // ════════════════════════════════════════ let obStep=0; const OB_TOTAL=8; const OB_STEPS=[ {title:"Let's set up Troy.",sub:"3 minutes. The more you share, the better Troy knows you. Edit anything later in Admin.", render:()=>` `, save:()=>{if(!Mem.deep.profile)Mem.deep.profile={}; Mem.deep.profile.name=document.getElementById('ob-name')?.value||''; Mem.deep.profile.nick=document.getElementById('ob-nick')?.value||''; Mem.deep.profile.birthday=document.getElementById('ob-bday')?.value||''; Mem.deep.profile.city=document.getElementById('ob-city')?.value||''; Mem.deep.profile.tz=document.getElementById('ob-tz')?.value||'America/Boise';}}, {title:"Your family.",sub:"Troy remembers the people who matter — so he never misses a birthday or forgets who's who.", render:()=>{const fam=Mem.deep?.profile?.family||[]; return `
${fam.map((f,i)=>`
`).join('')}
`;}, save:()=>{}}, {title:"Where you work.",sub:"Your company and team. Troy uses this context in every conversation.", render:()=>{const w=Mem.deep?.profile?.work||{}; return `
${(w.team||[]).map((m,i)=>`
`).join('')}
`;}, save:()=>{if(!Mem.deep.profile)Mem.deep.profile={};if(!Mem.deep.profile.work)Mem.deep.profile.work={}; Mem.deep.profile.work.company=document.getElementById('ob-company')?.value||''; Mem.deep.profile.work.role=document.getElementById('ob-role')?.value||''; Mem.deep.profile.work.industry=document.getElementById('ob-industry')?.value||'';}}, {title:"Your email.",sub:"Connect inboxes so Troy can surface what matters and help manage your inbox.", render:()=>{const em=Mem.deep?.profile?.email||{}; return `
Gmail
${Mem.deep?.googleEmail?'✓ Connected: '+Mem.deep.googleEmail:'Not connected'}
Yahoo Mail
`;}, save:()=>{if(!Mem.deep.profile)Mem.deep.profile={};if(!Mem.deep.profile.email)Mem.deep.profile.email={}; Mem.deep.profile.email.yahooUser=document.getElementById('ob-yahoo-user')?.value||''; Mem.deep.profile.email.yahooPass=document.getElementById('ob-yahoo-pass')?.value||'';}}, {title:"Social & professional.",sub:"LinkedIn, handles — Troy can draft posts and track your presence.", render:()=>{const s=Mem.deep?.profile?.social||{}; return ` `;}, save:()=>{if(!Mem.deep.profile)Mem.deep.profile={}; Mem.deep.profile.social={linkedin:document.getElementById('ob-linkedin')?.value||'',twitter:document.getElementById('ob-twitter')?.value||'',instagram:document.getElementById('ob-instagram')?.value||'',facebook:document.getElementById('ob-facebook')?.value||''};}}, {title:"Investments.",sub:"No passwords needed — Troy watches prices and tracks due dates. Stored locally only.", render:()=>{const tickers=Mem.deep?.profile?.tickers||['NVDA','TSM','CEG','XRP','BTC'];const f=Mem.deep?.profile?.finance||{}; return `
${tickers.map((t,i)=>`
${t}
`).join('')}
`;}, save:()=>{if(!Mem.deep.profile)Mem.deep.profile={};if(!Mem.deep.profile.finance)Mem.deep.profile.finance={}; Mem.deep.profile.finance.cards=(document.getElementById('ob-cards')?.value||'').split(',').map(s=>s.trim()).filter(Boolean);}}, {title:"How Troy should talk.",sub:"Dial it in. Adjustable anytime in Admin.", render:()=>{const pr=Mem.deep?.profile?.prefs||{}; return `
BriefDetailed
FormalCasual
StraightFunny
${['Weather','Stocks','Email','Calendar','Sports','News'].map(t=>`
${t}
`).join('')}
`;}, save:()=>{if(!Mem.deep.profile)Mem.deep.profile={}; const chips=document.querySelectorAll('#ob-card .adm-chip.active'); Mem.deep.profile.prefs={brevity:parseInt(document.getElementById('ob-brevity')?.value||2),formality:parseInt(document.getElementById('ob-formality')?.value||4),humor:parseInt(document.getElementById('ob-humor')?.value||4),proactiveTopics:Array.from(chips).map(c=>c.dataset.topic).filter(Boolean),avoidTopics:document.getElementById('ob-avoid')?.value||''};}}, {title:"Pick Troy's voice.",sub:"All three are conversational American male. Tap preview to hear.", render:()=>{const cur=Mem.deep?.profile?.voice||EL_VOICE; return ADM_VOICES.map(v=>`
${v.name}
${v.desc}
${v.id===cur?'
':''}
`).join('');}, save:()=>{}} ]; function obSelectVoice(id,el){document.querySelectorAll('.ob-voice-opt').forEach(r=>r.classList.remove('sel'));el.classList.add('sel');if(!Mem.deep.profile)Mem.deep.profile={};Mem.deep.profile.voice=id;if(PREFS)PREFS.voice=id;Mem.save();} function obPreviewVoice(id){const old=PREFS?.voice;if(PREFS)PREFS.voice=id;speakReply("Yeah so Tuesday looks good — low wind, clear skies. Your board call is Wednesday afternoon, so Tuesday's your move if you want the full day.");setTimeout(()=>{if(PREFS&&old)PREFS.voice=old;},500);} function obFamAdd(){if(!Mem.deep.profile)Mem.deep.profile={};if(!Mem.deep.profile.family)Mem.deep.profile.family=[];Mem.deep.profile.family.push({name:'',relation:''});Mem.save();obRenderStep(obStep);} function obFamUpdate(i,key,val){if(Mem.deep?.profile?.family?.[i]){Mem.deep.profile.family[i][key]=val;Mem.save();}} function obFamRemove(i){Mem.deep.profile.family.splice(i,1);Mem.save();obRenderStep(obStep);} function obTeamAdd(){if(!Mem.deep.profile.work)Mem.deep.profile.work={};if(!Mem.deep.profile.work.team)Mem.deep.profile.work.team=[];Mem.deep.profile.work.team.push({name:'',role:''});Mem.save();obRenderStep(obStep);} function obTeamUpdate(i,key,val){if(Mem.deep?.profile?.work?.team?.[i]){Mem.deep.profile.work.team[i][key]=val;Mem.save();}} function obAddTicker(){const inp=document.getElementById('ob-ticker-inp'),val=(inp?.value||'').trim().toUpperCase();if(!val)return;if(!Mem.deep.profile)Mem.deep.profile={};if(!Mem.deep.profile.tickers)Mem.deep.profile.tickers=[];val.split(/[,\s]+/).forEach(t=>{if(t&&!Mem.deep.profile.tickers.includes(t))Mem.deep.profile.tickers.push(t);});if(inp)inp.value='';Mem.save();obRenderStep(obStep);} function obRemoveTicker(i){Mem.deep.profile.tickers.splice(i,1);Mem.save();obRenderStep(obStep);} function obConnectGoogle(){if(typeof signIn==='function')signIn();} function obRenderStep(n){ const step=OB_STEPS[n];if(!step)return; document.getElementById('ob-step-lbl').textContent=`Step ${n+1} of ${OB_TOTAL}`; document.getElementById('ob-card').innerHTML=`
Step ${n+1} of ${OB_TOTAL}
${step.title}
${step.sub}
${step.render()}`; const prog=document.getElementById('ob-progress'); if(prog)prog.innerHTML=Array.from({length:OB_TOTAL},(_,i)=>`
`).join(''); const back=document.getElementById('ob-back');if(back)back.style.display=n===0?'none':''; const nxt=document.getElementById('ob-next');if(nxt)nxt.textContent=n===OB_TOTAL-1?"Let's go →":'Next →'; } function obNext(){const step=OB_STEPS[obStep];if(step?.save)step.save();Mem.save();if(obStep>=OB_TOTAL-1){obComplete();return;}obStep++;obRenderStep(obStep);document.getElementById('ob-overlay')?.scrollTo(0,0);} function obBack(){if(obStep===0)return;obStep--;obRenderStep(obStep);} function obSkipAll(){if(!confirm('Skip setup? Run anytime from Admin.'))return;obComplete();} function obComplete(){if(!Mem.deep.onboarding)Mem.deep.onboarding={};Mem.deep.onboarding.complete=true;Mem.deep.onboarding.completedAt=Date.now();Mem.save();document.getElementById('ob-overlay').style.display='none';showToast("Troy is ready. Let's go.");} function obStart(){obStep=0;document.getElementById('ob-overlay').style.display='flex';if(!Mem.deep.profile)Mem.deep.profile={};obRenderStep(0);} // Init: check onboarding + load admin on tab switch document.addEventListener('DOMContentLoaded',()=>{ setTimeout(()=>{ admLoad(); if(Mem.deep?.onboarding?.complete!==true) setTimeout(obStart,1200); },900); }); // ════════════════════════════════════════ // CONNECT TAB // ════════════════════════════════════════ // ── SVG Icon Map — clean monochrome line icons, Obsidian-themed ────────── function getConnectIcon(id) { const icons = { // Core gmail: ``, telegram: ``, // Communication outlook: ``, slack: ``, discord: ``, whatsapp: ``, // Productivity gcal: ``, gdrive: ``, notion: ``, todoist: ``, asana: ``, trello: ``, zoom: ``, calendly: ``, docusign: ``, // Finance plaid: ``, coinbase: ``, yahoo: ``, coingecko: ``, alphav: ``, quickbooks: ``, // Health ringconn: ``, whoop: ``, oura: ``, fitbit: ``, strava: ``, garmin: ``, appleh: ``, dexcom: ``, // Travel uber: ``, lyft: ``, airbnb: ``, gmaps: ``, flightaware: ``, openweather: ``, tripadvisor: ``, applecar: ``, // Entertainment spotify: ``, youtube: ``, applemusic: ``, netflix: ``, // Social twitter: ``, linkedin: ``, instagram: ``, // Smart Home hue: ``, smartthings: ``, ring: ``, nest: ``, homeassist: ``, ifttt: ``, // News newsapi: ``, nyt: ``, guardian: ``, hackernews: ``, // Food doordash: ``, ubereats: ``, instacart: ``, opentable: ``, starbucks: ``, yelp: ``, chipotle: ``, // Shopping amazon: ``, walmart: ``, target: ``, costco: ``, // Packages fedex: ``, ups: ``, usps: ``, // Payments venmo: ``, cashapp: ``, stripe: ``, // Pharmacy goodrx: ``, cvs: ``, zocdoc: ``, // Movies fandango: ``, imdb: ``, // Real Estate zillow: ``, redfin: ``, // Sports espn: ``, nfl: ``, nba: ``, nhl: ``, mlb: ``, // Utilities wolframalpha:``, wikipedia: ``, // AI & Dev openai: ``, gemini: ``, grok: ``, perplexity: ``, github: ``, zapier: ``, }; return icons[id] || ``; } // ── CONNECT SERVICES ────────────────────────────────────────────────────── // Types: // builtin — always connected, no setup // auto — Google OAuth (one tap) // plaid — Plaid bank link // checkin — Morning check-in card // question — Ask one simple question, store answer // open — Just a launch button, no setup needed const CONNECT_SERVICES = [ // ── Always On ──────────────────────────────────────────────────────────── { id:'openai', cat:'Always On', name:'OpenAI', tag:'GPT-4o · your AI brain', type:'builtin', alwaysConnected:true }, { id:'gemini', cat:'Always On', name:'Gemini', tag:'Google AI', type:'builtin', alwaysConnected:true }, { id:'grok', cat:'Always On', name:'Grok', tag:'xAI models', type:'builtin', alwaysConnected:true }, { id:'perplexity', cat:'Always On', name:'Perplexity', tag:'AI web search', type:'builtin', alwaysConnected:true }, { id:'yahoo', cat:'Always On', name:'Yahoo Finance', tag:'Live market data', type:'builtin', alwaysConnected:true }, { id:'coingecko', cat:'Always On', name:'CoinGecko', tag:'Crypto prices', type:'builtin', alwaysConnected:true }, { id:'gmaps', cat:'Always On', name:'Google Maps', tag:'Navigation & places', type:'builtin', alwaysConnected:true }, { id:'openweather', cat:'Always On', name:'Weather', tag:'Open-Meteo live forecasts', type:'builtin', alwaysConnected:true }, { id:'wikipedia', cat:'Always On', name:'Wikipedia', tag:'Free knowledge', type:'builtin', alwaysConnected:true }, { id:'telegram', cat:'Always On', name:'Telegram', tag:'Your AI channel', type:'builtin', alwaysConnected:true }, // ── Google ──────────────────────────────────────────────────────────────── { id:'gmail', cat:'Google', name:'Gmail', tag:'Your inbox in Troy', type:'auto', autoCheck:() => !!gToken }, { id:'gcal', cat:'Google', name:'Calendar', tag:'Schedule & upcoming events', type:'auto', autoCheck:() => !!gToken }, { id:'gdrive', cat:'Google', name:'Drive', tag:'Your files', type:'auto', autoCheck:() => !!gToken }, { id:'youtube', cat:'Google', name:'YouTube', tag:'Video & subscriptions', type:'auto', autoCheck:() => !!gToken }, // ── Finance ─────────────────────────────────────────────────────────────── { id:'plaid', cat:'Finance', name:'Bank Accounts', tag:'Balances & transactions', type:'plaid', autoCheck:() => !!localStorage.getItem('plaid_access_token') }, { id:'coinbase', cat:'Finance', name:'Coinbase', tag:'Your crypto portfolio', type:'builtin', alwaysConnected:true }, { id:'cashapp', cat:'Finance', name:'Cash App', tag:'Your $cashtag', type:'question', question:"What's your Cash App $cashtag?", placeholder:'$owenhammond' }, { id:'venmo', cat:'Finance', name:'Venmo', tag:'Your Venmo username', type:'question', question:"What's your Venmo username?", placeholder:'@owenhammond' }, // ── Health ──────────────────────────────────────────────────────────────── { id:'ringconn', cat:'Health', name:'Morning Check-In', tag:'Log how you feel each day', type:'checkin', autoCheck:() => !!(JSON.parse(localStorage.getItem('valet_biometrics')||'null')?._ts) }, { id:'whoop', cat:'Health', name:'WHOOP', tag:'Your WHOOP account', type:'question', question:"What email do you use for WHOOP?", placeholder:'you@email.com' }, { id:'oura', cat:'Health', name:'Oura Ring', tag:'Your Oura account', type:'question', question:"What email do you use for Oura?", placeholder:'you@email.com' }, { id:'strava', cat:'Health', name:'Strava', tag:'Your running & rides', type:'question', question:"What's your Strava profile username?", placeholder:'owenhammond' }, { id:'garmin', cat:'Health', name:'Garmin', tag:'GPS fitness', type:'open', deepUrl:'https://connect.garmin.com' }, { id:'fitbit', cat:'Health', name:'Fitbit', tag:'Activity & heart rate', type:'open', deepUrl:'https://fitbit.com' }, { id:'dexcom', cat:'Health', name:'Dexcom G7', tag:'Continuous glucose', type:'question', question:"What's your Dexcom account email?", placeholder:'you@email.com' }, // ── Communication ───────────────────────────────────────────────────────── { id:'outlook', cat:'Communication',name:'Outlook', tag:'Your Microsoft email', type:'question', question:"What's your Outlook or Microsoft email?", placeholder:'you@outlook.com' }, { id:'slack', cat:'Communication',name:'Slack', tag:'Team messaging', type:'question', question:"What's your Slack workspace address?", placeholder:'company.slack.com' }, { id:'discord', cat:'Communication',name:'Discord', tag:'Your Discord', type:'question', question:"What's your Discord username?", placeholder:'owenhammond' }, { id:'whatsapp', cat:'Communication',name:'WhatsApp', tag:'Open WhatsApp', type:'open', deepUrl:'https://web.whatsapp.com' }, // ── Productivity ────────────────────────────────────────────────────────── { id:'notion', cat:'Productivity', name:'Notion', tag:'Your notes & docs', type:'question', question:"What email do you use for Notion?", placeholder:'you@email.com' }, { id:'todoist', cat:'Productivity', name:'Todoist', tag:'Task list', type:'open', deepUrl:'https://todoist.com/app' }, { id:'zoom', cat:'Productivity', name:'Zoom', tag:'Video meetings', type:'question', question:"What email do you use for Zoom?", placeholder:'you@email.com' }, { id:'calendly', cat:'Productivity', name:'Calendly', tag:'Your scheduling link', type:'question', question:"What's your Calendly link?", placeholder:'calendly.com/yourname' }, { id:'docusign', cat:'Productivity', name:'DocuSign', tag:'e-Signatures', type:'open', deepUrl:'https://account.docusign.com' }, { id:'trello', cat:'Productivity', name:'Trello', tag:'Boards & tasks', type:'open', deepUrl:'https://trello.com' }, // ── Travel ──────────────────────────────────────────────────────────────── { id:'uber', cat:'Travel', name:'Uber', tag:'Rides on demand', type:'question', question:"What's your home address for Uber?", placeholder:'123 Main St, Eagle ID' }, { id:'lyft', cat:'Travel', name:'Lyft', tag:'Rideshare', type:'open', deepUrl:'https://lyft.com' }, { id:'airbnb', cat:'Travel', name:'Airbnb', tag:'Short-term stays', type:'open', deepUrl:'https://airbnb.com' }, { id:'flightaware', cat:'Travel', name:'Airline', tag:'Flight tracking', type:'question', question:"Which airline do you fly most?", placeholder:'Delta, United, Southwest…' }, { id:'tripadvisor', cat:'Travel', name:'TripAdvisor', tag:'Hotels & restaurants', type:'open', deepUrl:'https://tripadvisor.com' }, // ── Entertainment ───────────────────────────────────────────────────────── { id:'spotify', cat:'Entertainment',name:'Spotify', tag:'Music streaming', type:'question', question:"What's your Spotify username?", placeholder:'spotifyusername' }, { id:'applemusic', cat:'Entertainment',name:'Apple Music', tag:'iOS music', type:'open', deepUrl:'https://music.apple.com' }, { id:'netflix', cat:'Entertainment',name:'Netflix', tag:'Streaming', type:'question', question:"What email do you use for Netflix?", placeholder:'you@email.com' }, // ── Social ──────────────────────────────────────────────────────────────── { id:'twitter', cat:'Social', name:'X / Twitter', tag:'Your handle', type:'question', question:"What's your X / Twitter handle?", placeholder:'@owenhammond' }, { id:'linkedin', cat:'Social', name:'LinkedIn', tag:'Professional profile', type:'question', question:"What's your LinkedIn profile URL?", placeholder:'linkedin.com/in/owenhammond' }, { id:'instagram', cat:'Social', name:'Instagram', tag:'Your username', type:'question', question:"What's your Instagram username?", placeholder:'@owenhammond' }, // ── Smart Home ──────────────────────────────────────────────────────────── { id:'hue', cat:'Smart Home', name:'Philips Hue', tag:'Smart lighting', type:'open', deepUrl:'https://www.philips-hue.com/en-us/support' }, { id:'nest', cat:'Smart Home', name:'Google Nest', tag:'Thermostat & cameras', type:'open', deepUrl:'https://home.google.com/intl/en_us/get-app/' }, { id:'ring', cat:'Smart Home', name:'Ring', tag:'Doorbell & security', type:'open', deepUrl:'https://ring.com/app' }, { id:'smartthings', cat:'Smart Home', name:'SmartThings', tag:'Samsung smart home', type:'open', deepUrl:'https://app.smartthings.com' }, { id:'ifttt', cat:'Smart Home', name:'IFTTT', tag:'Automation', type:'open', deepUrl:'https://ifttt.com' }, // ── News ────────────────────────────────────────────────────────────────── { id:'newsapi', cat:'News', name:'News Topics', tag:'What do you follow?', type:'question', question:"What topics do you want Troy to follow?", placeholder:'Healthcare, crypto, Idaho politics…' }, { id:'nyt', cat:'News', name:'New York Times', tag:'Your NYT account', type:'question', question:"What email do you use for the NYT?", placeholder:'you@email.com' }, { id:'guardian', cat:'News', name:'The Guardian', tag:'Global news', type:'open', deepUrl:'https://theguardian.com' }, // ── Food ───────────────────────────────────────────────────────────────── { id:'doordash', cat:'Food', name:'DoorDash', tag:'Your delivery address', type:'question', question:"What's your usual DoorDash delivery address?", placeholder:'North Boise, Idaho' }, { id:'ubereats', cat:'Food', name:'Uber Eats', tag:'Food delivery', type:'open', deepUrl:'https://ubereats.com' }, { id:'instacart', cat:'Food', name:'Instacart', tag:'Grocery delivery', type:'open', deepUrl:'https://instacart.com' }, { id:'opentable', cat:'Food', name:'OpenTable', tag:'Restaurant reservations', type:'question', question:"What city do you usually dine out in?", placeholder:'Boise, Eagle, Meridian…' }, { id:'starbucks', cat:'Food', name:'Starbucks', tag:'Mobile order & rewards', type:'question', question:"What's your Starbucks rewards email?", placeholder:'you@email.com' }, { id:'chipotle', cat:'Food', name:'Chipotle', tag:'Online ordering', type:'open', deepUrl:'https://chipotle.com/order' }, { id:'yelp', cat:'Food', name:'Yelp', tag:'Restaurants near you', type:'open', deepUrl:'https://yelp.com' }, // ── Shopping ────────────────────────────────────────────────────────────── { id:'amazon', cat:'Shopping', name:'Amazon', tag:'Your Amazon account', type:'question', question:"What email do you use for Amazon?", placeholder:'you@email.com' }, { id:'target', cat:'Shopping', name:'Target', tag:'Shop & pickup', type:'open', deepUrl:'https://target.com' }, { id:'walmart', cat:'Shopping', name:'Walmart', tag:'Everyday essentials', type:'open', deepUrl:'https://walmart.com' }, { id:'costco', cat:'Shopping', name:'Costco', tag:'Bulk & wholesale', type:'open', deepUrl:'https://costco.com' }, // ── Packages ────────────────────────────────────────────────────────────── { id:'fedex', cat:'Packages', name:'FedEx', tag:'Your FedEx delivery email', type:'question', question:"What email gets your FedEx delivery notifications?", placeholder:'you@email.com' }, { id:'ups', cat:'Packages', name:'UPS', tag:'Your UPS delivery email', type:'question', question:"What email gets your UPS delivery notifications?", placeholder:'you@email.com' }, { id:'usps', cat:'Packages', name:'USPS', tag:'Track mail & packages', type:'open', deepUrl:'https://tools.usps.com/go/TrackConfirmAction' }, // ── Pharmacy & Health ───────────────────────────────────────────────────── { id:'goodrx', cat:'Pharmacy', name:'GoodRx', tag:'Prescription price check', type:'question', question:"What's your zip code for GoodRx prices?", placeholder:'83616' }, { id:'cvs', cat:'Pharmacy', name:'CVS', tag:'Pharmacy & Rx', type:'open', deepUrl:'https://cvs.com' }, { id:'zocdoc', cat:'Pharmacy', name:'ZocDoc', tag:'Book doctor appointments', type:'question', question:"What's your zip code for doctor search?", placeholder:'83616' }, // ── Sports ──────────────────────────────────────────────────────────────── { id:'espn', cat:'Sports', name:'ESPN', tag:'Scores & highlights', type:'open', deepUrl:'https://espn.com' }, { id:'nfl', cat:'Sports', name:'NFL', tag:'Football', type:'builtin', alwaysConnected:true }, { id:'nba', cat:'Sports', name:'NBA', tag:'Basketball', type:'builtin', alwaysConnected:true }, { id:'nhl', cat:'Sports', name:'NHL', tag:'Hockey', type:'builtin', alwaysConnected:true }, { id:'mlb', cat:'Sports', name:'MLB', tag:'Baseball', type:'builtin', alwaysConnected:true }, // ── Movies ──────────────────────────────────────────────────────────────── { id:'fandango', cat:'Movies', name:'Fandango', tag:'Movie tickets', type:'question', question:"What's your zip code for movie showtimes?", placeholder:'83616' }, { id:'imdb', cat:'Movies', name:'IMDb', tag:'Movie & TV info', type:'open', deepUrl:'https://imdb.com' }, // ── Real Estate ─────────────────────────────────────────────────────────── { id:'zillow', cat:'Real Estate', name:'Zillow', tag:'Home values & listings', type:'question', question:"What zip code should I watch for real estate?", placeholder:'83616' }, { id:'redfin', cat:'Real Estate', name:'Redfin', tag:'Home search & estimates', type:'open', deepUrl:'https://redfin.com' }, // ── Dev & Tools ─────────────────────────────────────────────────────────── { id:'github', cat:'Dev', name:'GitHub', tag:'Your code repos', type:'question', question:"What's your GitHub username?", placeholder:'owenhammond' }, { id:'zapier', cat:'Dev', name:'Zapier', tag:'Automation flows', type:'open', deepUrl:'https://zapier.com' }, { id:'wolframalpha', cat:'Dev', name:'Wolfram Alpha', tag:'Calculations & facts', type:'builtin', alwaysConnected:true }, ]; // ── Connection helpers ──────────────────────────────────────────────────── function connectIsConnected(svc) { if (svc.alwaysConnected || svc.type === 'builtin') return true; if (svc.autoCheck && svc.autoCheck()) return true; if (svc.type === 'question') return !!localStorage.getItem('valet_qa_' + svc.id); if (svc.type === 'plaid') return !!localStorage.getItem('plaid_access_token'); if (svc.type === 'checkin') return !!(JSON.parse(localStorage.getItem('valet_biometrics')||'null')?._ts); if (typeof Mem !== 'undefined' && Mem.deep?.integrations?.[svc.id]?.connected) return true; return false; } function connectGetAnswer(svcId) { try { const v = localStorage.getItem('valet_qa_' + svcId); return v ? JSON.parse(v).answer : null; } catch(e) { return null; } } function connectRenderCategories(filter) { const wrap = document.getElementById('connectCategoriesWrap'); if (!wrap) return; const cats = [...new Set(CONNECT_SERVICES.map(s => s.cat))]; const fl = (filter || '').toLowerCase().trim(); let html = ''; cats.forEach(cat => { const svcs = CONNECT_SERVICES.filter(s => s.cat === cat && (!fl || s.name.toLowerCase().includes(fl) || s.tag.toLowerCase().includes(fl))); if (!svcs.length) return; html += `
`; html += `
${cat}
`; html += `
`; svcs.forEach(svc => { const conn = connectIsConnected(svc); html += `
`; html += `
${getConnectIcon(svc.id)}
`; html += `
${svc.name}
`; html += `
${svc.tag}
`; if (svc.type === 'open') { html += ``; } else if (svc.type === 'builtin') { html += ``; } else if (conn) { html += ``; } else { const label = svc.type === 'auto' ? 'Sign In' : svc.type === 'plaid' ? 'Link Bank' : svc.type === 'checkin' ? 'Log Today' : 'Set Up'; html += ``; } html += `
`; }); html += `
`; }); if (!html) html = `
No services match "${filter}"
`; wrap.innerHTML = html; } function connectFilterCards(val) { connectRenderCategories(val); } // ── Modal ────────────────────────────────────────────────────────────────── let _connectCurrentSvcId = null; function openConnectModal(svcId) { const svc = CONNECT_SERVICES.find(s => s.id === svcId); if (!svc) return; _connectCurrentSvcId = svcId; const conn = connectIsConnected(svc); let body = `
${getConnectIcon(svc.id)}
`; body += `
${svc.name}
`; if (svc.type === 'builtin') { body += `
✓ Built-in — always active
`; } else if (svc.type === 'auto') { if (conn) { const gEmail = (typeof Mem !== 'undefined' && Mem.deep?.googleEmail) || ''; body += `
✓ Connected${gEmail ? ' · ' + gEmail : ''}
`; body += ``; } else { body += `
One tap — connects Gmail, Calendar, Drive, and YouTube all at once.
`; body += ``; } } else if (svc.type === 'question') { const existing = connectGetAnswer(svcId) || ''; if (conn && existing) { body += `
✓ ${existing}
`; body += `
${svc.question}
`; } else { body += `
${svc.question}
`; } body += ``; body += ``; if (conn) body += ``; } else if (svc.type === 'checkin') { const bio = JSON.parse(localStorage.getItem('valet_biometrics') || 'null'); if (bio && bio._ts) { const ageH = Math.round((Date.now() - bio._ts) / 3600000); const rlabels = ['','Rough','Tired','Okay','Good','Dialed']; body += `
✓ Logged ${ageH < 1 ? 'just now' : ageH + 'h ago'}
`; const parts = []; if (bio.readiness != null) parts.push(bio._src === 'checkin' ? rlabels[bio.readiness] : `Readiness ${bio.readiness}%`); if (bio.sleep != null) parts.push(`Sleep ${bio.sleep}h`); if (bio.hrv != null) parts.push(`HRV ${bio.hrv}ms`); if (parts.length) body += `
${parts.join(' · ')}
`; body += ``; body += ``; } else { body += `
Log how you feel each morning from the Home tab. Takes 10 seconds. Troy uses it to understand your day.
`; body += ``; } } else if (svc.type === 'plaid') { const hasToken = !!localStorage.getItem('plaid_access_token'); if (hasToken) { body += `
✓ Bank accounts connected
`; body += ``; } else { body += `
Log into your bank once — same secure system used by Venmo and Robinhood. Troy sees your balances and can alert you to unusual spending.
`; body += ``; } } const modal = document.getElementById('connectModal'); const sheet = document.getElementById('connectModalSheet'); document.getElementById('connectModalContent').innerHTML = body; modal.style.display = 'block'; modal.style.pointerEvents = 'all'; requestAnimationFrame(() => { sheet.style.transform = 'translateY(0)'; }); } function closeConnectModal() { const sheet = document.getElementById('connectModalSheet'); const modal = document.getElementById('connectModal'); sheet.style.transform = 'translateY(100%)'; setTimeout(() => { modal.style.display = 'none'; modal.style.pointerEvents = 'none'; }, 350); _connectCurrentSvcId = null; } function connectSaveQa(svcId) { const inp = document.getElementById('connectQaInp'); const val = (inp ? inp.value : '').trim(); if (!val) { showToast('Please enter an answer'); return; } localStorage.setItem('valet_qa_' + svcId, JSON.stringify({ answer: val, ts: Date.now() })); closeConnectModal(); setTimeout(() => { connectRenderCategories(document.getElementById('connectSearch')?.value || ''); showToast('Saved ✓'); }, 400); } function connectClearQa(svcId) { localStorage.removeItem('valet_qa_' + svcId); closeConnectModal(); setTimeout(() => { connectRenderCategories(document.getElementById('connectSearch')?.value || ''); showToast('Removed'); }, 400); } // Legacy stubs (keep for any existing calls) function connectSaveKey(svcId) { connectSaveQa(svcId); } function connectMarkDeeplink(svcId) { closeConnectModal(); showToast('Marked as set up ✓'); } function connectDisconnect(svcId) { connectClearQa(svcId); } // ── Plaid bank connection ───────────────────────────────────────────────── async function plaidLaunchLink() { try { showToast('Connecting to Plaid…'); const res = await fetch('/api/plaid', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'create_link_token' }) }); const { link_token, error } = await res.json(); if (error || !link_token) { showToast('Plaid not configured — add keys in Admin'); return; } if (!window.Plaid) { await new Promise((resolve, reject) => { const s = document.createElement('script'); s.src = 'https://cdn.plaid.com/link/v2/stable/link-initialize.js'; s.onload = resolve; s.onerror = reject; document.head.appendChild(s); }); } const handler = window.Plaid.create({ token: link_token, onSuccess: async (public_token) => { showToast('Linking account…'); const r = await fetch('/api/plaid', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'exchange_token', public_token }) }); const d = await r.json(); if (d.access_token) { localStorage.setItem('plaid_access_token', d.access_token); showToast('Bank connected ✓'); connectRenderCategories(''); } }, onExit: () => {} }); handler.open(); } catch(e) { showToast('Bank connection failed'); } } // Initialize connect categories function connectTabInit() { const wrap = document.getElementById('connectCategoriesWrap'); if (wrap && !wrap.dataset.rendered) { wrap.dataset.rendered = '1'; connectRenderCategories(''); } }